refactor(workflow-generator): simplify meta to routing booleans + log-to-file

- planner: { ready }, coder: { done }, tester: { passed }, committer: { success }
- planner/coder: createCursorRole, tester: createHermesRole
- committer: direct spawn, output to .log file
- moderator: coder loop (max 5), committer fail → coder
- bundle 24kb → 8.7kb

Fixes #5
This commit is contained in:
小橘 2026-04-28 10:22:57 +00:00
parent bf77e3452a
commit d638623456
9 changed files with 188 additions and 810 deletions

View File

@ -17,13 +17,12 @@ export function buildWorkflowGenerator({
provider, provider,
nerveRoot, nerveRoot,
}: BuildWorkflowGeneratorDeps): WorkflowDefinition<WorkflowMeta> { }: BuildWorkflowGeneratorDeps): WorkflowDefinition<WorkflowMeta> {
const workflowsDir = join(nerveRoot, "workflows");
return { return {
name: "workflow-generator", name: "workflow-generator",
roles: { roles: {
planner: buildPlannerRole({ provider, nerveRoot, workflowsDir }), planner: buildPlannerRole({ provider, cwd: nerveRoot }),
coder: buildCoderRole({ nerveRoot, workflowsDir }), coder: buildCoderRole({ provider, cwd: nerveRoot }),
tester: buildTesterRole({ nerveRoot }), tester: buildTesterRole({ provider }),
committer: buildCommitterRole({ nerveRoot }), committer: buildCommitterRole({ nerveRoot }),
}, },
moderator, moderator,

View File

@ -12,34 +12,32 @@ export type WorkflowMeta = {
committer: CommitterMeta; committer: CommitterMeta;
}; };
const MAX_CODER_ITERATIONS = 5;
export const moderator: Moderator<WorkflowMeta> = (context) => { export const moderator: Moderator<WorkflowMeta> = (context) => {
if (context.steps.length === 0) { if (context.steps.length === 0) return "planner";
return "planner";
}
const last = context.steps[context.steps.length - 1]; const last = context.steps[context.steps.length - 1];
const coderCount = context.steps.filter((s) => s.role === "coder").length;
if (last.role === "planner") { if (last.role === "planner") {
if (last.meta.workflowName.trim().length > 0) return "coder"; return last.meta.ready ? "coder" : END;
const plannerAttempts = context.steps.filter((s) => s.role === "planner").length;
return plannerAttempts < 3 ? "planner" : END;
} }
if (last.role === "coder") { if (last.role === "coder") {
if (last.meta.lintPassed && last.meta.buildPassed) { if (last.meta.done) return "tester";
return "tester"; return coderCount < MAX_CODER_ITERATIONS ? "coder" : END;
}
if (last.meta.attempt < 3) {
return "coder";
}
return END;
} }
if (last.role === "tester") { if (last.role === "tester") {
if (last.meta.passed) { if (last.meta.passed) return "committer";
return "committer"; return coderCount < MAX_CODER_ITERATIONS ? "coder" : END;
}
if (last.meta.attempt < 3) {
return "coder";
}
return END;
} }
if (last.role === "committer") {
if (last.meta.success) return END;
return coderCount < MAX_CODER_ITERATIONS ? "coder" : END;
}
return END; return END;
}; };

View File

@ -1,254 +1,23 @@
import { existsSync, readFileSync } from "node:fs"; import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { join } from "node:path"; import { createCursorRole } from "@uncaged/nerve-workflow-utils";
import type { Role, RoleResult, WorkflowMessage } from "@uncaged/nerve-core";
import type { SpawnError } from "@uncaged/nerve-workflow-utils";
import { cursorAgent, isDryRun, spawnSafe } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
import type { PlannerMeta } from "../planner/index.js";
import type { TesterMeta } from "../tester/index.js";
import { coderPrompt } from "./prompt.js"; import { coderPrompt } from "./prompt.js";
import { z } from "zod";
export const coderMetaSchema = z.object({ export const coderMetaSchema = z.object({
workflowName: z.string().default(""), done: z.boolean().describe("true if the workflow files were created and build passes"),
attempt: z.number().default(1),
files: z
.object({
indexTs: z.boolean().default(false),
packageJson: z.boolean().default(false),
tsconfigJson: z.boolean().default(false),
})
.default({ indexTs: false, packageJson: false, tsconfigJson: false }),
lintPassed: z.boolean().default(false),
buildPassed: z.boolean().default(false),
lintLog: z.string().default(""),
buildLog: z.string().default(""),
cursorOutput: z.string().default(""),
reason: z.string().nullable().default(null),
}); });
export type CoderMeta = z.infer<typeof coderMetaSchema>; export type CoderMeta = z.infer<typeof coderMetaSchema>;
export type BuildCoderDeps = { export type BuildCoderDeps = {
nerveRoot: string; provider: LlmProvider;
workflowsDir: string; cwd: string;
}; };
function formatSpawnFailure(error: SpawnError): string { export function buildCoderRole({ provider, cwd }: BuildCoderDeps) {
if (error.kind === "spawn_failed") { return createCursorRole<CoderMeta>({
return error.message; cwd,
} mode: "default",
if (error.kind === "timeout") { prompt: async (threadId) => coderPrompt({ threadId }),
return `timeout stdout=${error.stdout.slice(0, 300)} stderr=${error.stderr.slice(0, 300)}`; extract: { provider, schema: coderMetaSchema },
}
return `exit ${error.exitCode} stderr=${error.stderr.slice(0, 500)}`;
}
function scanGeneratedCodePitfalls(source: string): string[] {
const issues: string[] = [];
if (/\bawait\s+import\s*\(/.test(source)) {
issues.push("Found await import() in generated workflow code");
}
if (/\bimport\s*\(\s*["'`]/.test(source) && !source.includes("Dynamic import required")) {
issues.push("Found undocumented dynamic import() call");
}
if (!/\bexport\s+default\s+/.test(source)) {
issues.push("Missing default export of WorkflowDefinition");
}
return issues;
}
async function runLintAndBuild(
workflowDir: string,
dry: boolean,
): Promise<{
lintPassed: boolean;
buildPassed: boolean;
lintLog: string;
buildLog: string;
reason: string | null;
}> {
const lintRun = await spawnSafe("pnpm", ["run", "check"], {
cwd: workflowDir,
env: null,
timeoutMs: 300_000,
dryRun: dry,
}); });
if (!lintRun.ok) {
return {
lintPassed: false,
buildPassed: false,
lintLog: formatSpawnFailure(lintRun.error),
buildLog: "",
reason: `lint failed: ${formatSpawnFailure(lintRun.error)}`,
};
}
const lintLog = lintRun.value.stderr.trim() || lintRun.value.stdout.trim() || "(no output)";
const tscRun = await spawnSafe("npx", ["tsc", "--noEmit"], {
cwd: workflowDir,
env: null,
timeoutMs: 300_000,
dryRun: dry,
});
if (!tscRun.ok) {
return {
lintPassed: true,
buildPassed: false,
lintLog,
buildLog: formatSpawnFailure(tscRun.error),
reason: `build failed: ${formatSpawnFailure(tscRun.error)}`,
};
}
const buildLog = tscRun.value.stderr.trim() || tscRun.value.stdout.trim() || "(no output)";
return { lintPassed: true, buildPassed: true, lintLog, buildLog, reason: null };
}
function lastMetaForRole<M>(messages: WorkflowMessage[], role: string): M | null {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === role) {
return messages[i].meta as M;
}
}
return null;
}
export function buildCoderRole({ nerveRoot, workflowsDir }: BuildCoderDeps): Role<CoderMeta> {
return async (start, messages) => {
const dry = isDryRun(start);
const plannerMeta = lastMetaForRole<PlannerMeta>(messages, "planner");
const previousTester = lastMetaForRole<TesterMeta>(messages, "tester");
const attempt = messages.filter((m) => m.role === "coder").length + 1;
if (plannerMeta === null || plannerMeta.workflowName.trim().length === 0) {
return {
content: "coder cannot continue: missing planner output",
meta: {
workflowName: "",
attempt,
files: { indexTs: false, packageJson: false, tsconfigJson: false },
lintPassed: false,
buildPassed: false,
lintLog: "",
buildLog: "",
cursorOutput: "",
reason: "missing planner output",
},
} satisfies RoleResult<CoderMeta>;
}
const wfName = plannerMeta.workflowName.trim();
const feedback =
previousTester !== null && previousTester.passed === false
? `\n\nPrevious tester failure to fix:\n${previousTester.reason}\n${previousTester.dryRunLog}\n`
: "";
const prompt = coderPrompt({
workflowsDir,
wfName,
planMarkdown: plannerMeta.planMarkdown,
plannerStructured: {
workflowName: plannerMeta.workflowName,
roles: plannerMeta.roles,
flowTransitions: plannerMeta.flowTransitions,
validationLoopsDesign: plannerMeta.validationLoopsDesign,
externalDeps: plannerMeta.externalDeps,
dataFlow: plannerMeta.dataFlow,
},
feedback,
nerveRoot,
});
const agentRun = await cursorAgent({
prompt,
mode: "default",
cwd: nerveRoot,
env: null,
timeoutMs: null,
dryRun: dry,
});
const workflowDir = join(workflowsDir, wfName);
const files = {
indexTs: existsSync(join(workflowDir, "index.ts")),
packageJson: existsSync(join(workflowDir, "package.json")),
tsconfigJson: existsSync(join(workflowDir, "tsconfig.json")),
};
const missing = [
files.indexTs ? null : "index.ts",
files.packageJson ? null : "package.json",
files.tsconfigJson ? null : "tsconfig.json",
].filter((x) => x !== null) as string[];
if (!agentRun.ok) {
return {
content: `coder failed: ${formatSpawnFailure(agentRun.error)}`,
meta: {
workflowName: wfName,
attempt,
files,
lintPassed: false,
buildPassed: false,
lintLog: "",
buildLog: "",
cursorOutput: "",
reason: formatSpawnFailure(agentRun.error),
},
} satisfies RoleResult<CoderMeta>;
}
if (missing.length > 0) {
return {
content: `coder failed: missing required files (${missing.join(", ")})`,
meta: {
workflowName: wfName,
attempt,
files,
lintPassed: false,
buildPassed: false,
lintLog: "",
buildLog: "",
cursorOutput: agentRun.value,
reason: `missing files: ${missing.join(", ")}`,
},
} satisfies RoleResult<CoderMeta>;
}
const source = readFileSync(join(workflowDir, "index.ts"), "utf-8");
const pitfalls = scanGeneratedCodePitfalls(source);
if (pitfalls.length > 0) {
return {
content: `coder static check failed:\n${pitfalls.join("\n")}`,
meta: {
workflowName: wfName,
attempt,
files,
lintPassed: false,
buildPassed: false,
lintLog: pitfalls.join("\n"),
buildLog: "",
cursorOutput: agentRun.value,
reason: pitfalls.join("; "),
},
} satisfies RoleResult<CoderMeta>;
}
const check = await runLintAndBuild(workflowDir, dry);
const passed = check.lintPassed && check.buildPassed;
return {
content: passed
? `coder PASS: lint+build ok\n\n${check.lintLog}\n\n${check.buildLog}`
: `coder FAIL: ${check.reason ?? "unknown error"}`,
meta: {
workflowName: wfName,
attempt,
files,
lintPassed: check.lintPassed,
buildPassed: check.buildPassed,
lintLog: check.lintLog,
buildLog: check.buildLog,
cursorOutput: agentRun.value,
reason: check.reason,
},
} satisfies RoleResult<CoderMeta>;
};
} }

View File

@ -1,39 +1,35 @@
export type CoderPromptParams = { export function coderPrompt({ threadId }: { threadId: string }): string {
workflowsDir: string; return `Read the workflow thread to get the planner's design and any tester feedback: \`nerve thread ${threadId}\`
wfName: string; Read the nerve-dev skill for workflow file structure and conventions: \`cat node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\`
planMarkdown: string; Also look at existing workflows in the \`workflows/\` directory for patterns.
plannerStructured: object;
feedback: string;
nerveRoot: string;
};
export function coderPrompt({ Implement the workflow the planner designed. If there is tester feedback in the thread, fix the issues it identified.
workflowsDir,
wfName,
planMarkdown,
plannerStructured,
feedback,
nerveRoot,
}: CoderPromptParams): string {
return `Implement a Nerve workflow package under ${workflowsDir}/${wfName}/.
Planner output: Required files for each workflow:
${planMarkdown} - \`workflows/<name>/index.ts\` — WorkflowDefinition default export
- \`workflows/<name>/package.json\` — with esbuild build script
Structured planner fields: - \`workflows/<name>/tsconfig.json\` — TypeScript config
${JSON.stringify(plannerStructured, null, 2)} - Update \`nerve.yaml\` with \`workflows.<name>\`
${feedback}
Required files:
1) ${workflowsDir}/${wfName}/index.ts
2) ${workflowsDir}/${wfName}/package.json
3) ${workflowsDir}/${wfName}/tsconfig.json
4) update ${nerveRoot}/nerve.yaml with workflows.${wfName}
Rules: Rules:
- keep WorkflowDefinition<WorkflowMeta> pattern - Keep the WorkflowDefinition<WorkflowMeta> pattern
- no dynamic import() - No dynamic import()
- use types (not interfaces) - Use types (not interfaces)
- include retry-aware moderator routing - Include retry-aware moderator routing
- write compile-ready TypeScript`; - Write compile-ready TypeScript
After creating all files, run inside the workflow directory:
\`\`\`
pnpm install --no-cache && pnpm build
\`\`\`
End your response with a JSON block:
\`\`\`json
{ "done": true }
\`\`\`
if build succeeded, or:
\`\`\`json
{ "done": false }
\`\`\`
if there were errors.`;
} }

View File

@ -1,190 +1,83 @@
import type { Role, RoleResult, WorkflowMessage } from "@uncaged/nerve-core"; import { writeFileSync, mkdirSync } from "node:fs";
import type { SpawnError } from "@uncaged/nerve-workflow-utils"; import { join } from "node:path";
import { cursorAgent, isDryRun, spawnSafe } from "@uncaged/nerve-workflow-utils"; import type { Role, RoleResult } from "@uncaged/nerve-core";
import { z } from "zod"; import { isDryRun, spawnSafe } from "@uncaged/nerve-workflow-utils";
import type { PlannerMeta } from "../planner/index.js";
import type { TesterMeta } from "../tester/index.js";
import { committerPrompt } from "./prompt.js";
export const committerMetaSchema = z.object({ export type CommitterMeta = {
invoked: z.boolean().default(false), success: boolean;
success: z.boolean().default(false), };
branch: z.string().nullable().default(null),
commitHash: z.string().nullable().default(null),
pushed: z.boolean().nullable().default(null),
log: z.string().default(""),
error: z.string().nullable().default(null),
});
export type CommitterMeta = z.infer<typeof committerMetaSchema>;
export type BuildCommitterDeps = { export type BuildCommitterDeps = {
nerveRoot: string; nerveRoot: string;
}; };
function formatSpawnFailure(error: SpawnError): string { function logPath(nerveRoot: string): string {
if (error.kind === "spawn_failed") { return join(nerveRoot, "logs", `committer-${Date.now()}.log`);
return error.message;
}
if (error.kind === "timeout") {
return `timeout stdout=${error.stdout.slice(0, 300)} stderr=${error.stderr.slice(0, 300)}`;
}
return `exit ${error.exitCode} stderr=${error.stderr.slice(0, 500)}`;
} }
function lastMetaForRole<M>(messages: WorkflowMessage[], role: string): M | null { function writeLog(path: string, content: string): void {
for (let i = messages.length - 1; i >= 0; i--) { mkdirSync(join(path, ".."), { recursive: true });
if (messages[i].role === role) { writeFileSync(path, content, "utf-8");
return messages[i].meta as M;
}
}
return null;
}
function inferWorkflowName(messages: WorkflowMessage[]): string {
const tester = lastMetaForRole<TesterMeta>(messages, "tester");
if (tester !== null && tester.workflowName.trim().length > 0) {
return tester.workflowName.trim();
}
const coder = lastMetaForRole<{ workflowName: string }>(messages, "coder");
if (coder !== null && coder.workflowName.trim().length > 0) {
return coder.workflowName.trim();
}
const planner = lastMetaForRole<PlannerMeta>(messages, "planner");
if (planner !== null && planner.workflowName.trim().length > 0) {
return planner.workflowName.trim();
}
return "";
}
async function runHermesCommitter(
task: string,
nerveRoot: string,
): Promise<CommitterMeta> {
const commandAttempts: Array<{ cmd: string; args: string[] }> = [
{ cmd: "hermes-agent", args: ["--cwd", nerveRoot, "--task", task] },
{ cmd: "hermes", args: ["agent", "--cwd", nerveRoot, "--task", task] },
];
for (const candidate of commandAttempts) {
const run = await spawnSafe(candidate.cmd, candidate.args, {
cwd: nerveRoot,
env: null,
timeoutMs: 600_000,
dryRun: false,
});
if (!run.ok) {
continue;
}
const text = `${run.value.stdout}\n${run.value.stderr}`;
const branch = text.match(/^BRANCH=(.*)$/m)?.[1]?.trim() ?? null;
const commitHash = text.match(/^COMMIT=(.*)$/m)?.[1]?.trim() ?? null;
const pushedText = text.match(/^PUSHED=(.*)$/m)?.[1]?.trim().toLowerCase() ?? "unknown";
const pushed = pushedText === "true" ? true : pushedText === "false" ? false : null;
return {
invoked: true,
success: true,
branch: branch && branch.length > 0 ? branch : null,
commitHash: commitHash && commitHash.length > 0 ? commitHash : null,
pushed,
log: text.slice(0, 20_000),
error: null,
};
}
const fallback = await cursorAgent({
prompt: `Run this git committer task in repository ${nerveRoot}:\n\n${task}`,
mode: "default",
cwd: nerveRoot,
env: null,
timeoutMs: null,
dryRun: false,
});
if (!fallback.ok) {
return {
invoked: true,
success: false,
branch: null,
commitHash: null,
pushed: null,
log: "",
error: `hermes and fallback both failed: ${formatSpawnFailure(fallback.error)}`,
};
}
const out = fallback.value;
const branch = out.match(/(?:branch|BRANCH)\s*[:=]\s*([^\s]+)/)?.[1] ?? null;
const commitHash = out.match(/[a-f0-9]{7,40}/)?.[0] ?? null;
return {
invoked: true,
success: true,
branch,
commitHash,
pushed: out.toLowerCase().includes("push") ? true : null,
log: out.slice(0, 20_000),
error: null,
};
} }
export function buildCommitterRole({ nerveRoot }: BuildCommitterDeps): Role<CommitterMeta> { export function buildCommitterRole({ nerveRoot }: BuildCommitterDeps): Role<CommitterMeta> {
return async (start, messages) => { return async (start, _messages) => {
const dry = isDryRun(start); const dry = isDryRun(start);
const planner = lastMetaForRole<PlannerMeta>(messages, "planner"); const file = logPath(nerveRoot);
const tester = lastMetaForRole<TesterMeta>(messages, "tester");
const workflowName = inferWorkflowName(messages);
const skipMeta: CommitterMeta = {
invoked: false,
success: false,
branch: null,
commitHash: null,
pushed: null,
log: "",
error: null,
};
if (planner === null || tester === null || workflowName.length === 0) {
return {
content: "committer skipped: missing planner/tester/workflowName context",
meta: { ...skipMeta, error: "missing committer context" },
} satisfies RoleResult<CommitterMeta>;
}
if (!tester.passed) {
return {
content: "committer skipped: tester not passed",
meta: { ...skipMeta, error: "tester not passed" },
} satisfies RoleResult<CommitterMeta>;
}
if (dry) { if (dry) {
writeLog(file, "[dry-run] committer skipped\n");
return { return {
content: "[dry-run] skipped hermes committer", content: `[dry-run] committer skipped — log: ${file}`,
meta: { meta: { success: true },
invoked: true,
success: true,
branch: "wf/dry-run",
commitHash: null,
pushed: null,
log: "[dry-run] skipped hermes committer",
error: null,
},
} satisfies RoleResult<CommitterMeta>; } satisfies RoleResult<CommitterMeta>;
} }
const task = committerPrompt({ const lines: string[] = [];
nerveRoot, let success = true;
workflowName,
userPrompt: planner.userPrompt,
testerReason: tester.reason,
});
const committed = await runHermesCommitter(task, nerveRoot); const run = async (cmd: string, args: string[]): Promise<boolean> => {
const r = await spawnSafe(cmd, args, { cwd: nerveRoot, env: null, timeoutMs: 60_000, dryRun: false });
if (r.ok) {
lines.push(`$ ${cmd} ${args.join(" ")}`);
if (r.value.stdout) lines.push(r.value.stdout);
if (r.value.stderr) lines.push(r.value.stderr);
lines.push("");
return true;
}
const e = r.error;
lines.push(`$ ${cmd} ${args.join(" ")} — FAILED`);
if (e.kind === "non_zero_exit") {
lines.push(`exit ${e.exitCode}`);
if (e.stdout) lines.push(e.stdout);
if (e.stderr) lines.push(e.stderr);
} else if (e.kind === "timeout") {
lines.push("timeout");
if (e.stdout) lines.push(e.stdout);
if (e.stderr) lines.push(e.stderr);
} else {
lines.push(e.message);
}
lines.push("");
return false;
};
await run("git", ["add", "-A"]);
const committed = await run("git", ["commit", "-m", "chore: add generated workflow"]);
if (!committed) {
success = false;
} else {
const pushed = await run("git", ["push"]);
if (!pushed) success = false;
}
const log = lines.join("\n");
writeLog(file, log);
const summary = success ? "committed and pushed" : "commit/push failed — see log";
return { return {
content: committed.success content: `committer: ${summary}\nLog: ${file}`,
? committed.log meta: { success },
: `committer failed: ${committed.error ?? "unknown"}`,
meta: committed,
} satisfies RoleResult<CommitterMeta>; } satisfies RoleResult<CommitterMeta>;
}; };
} }

View File

@ -1,142 +1,23 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type { Role, RoleResult } from "@uncaged/nerve-core";
import type { LlmProvider } from "@uncaged/nerve-workflow-utils"; import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { isDryRun, llmExtract, nerveAgentContext, readNerveYaml } from "@uncaged/nerve-workflow-utils"; import { createCursorRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
import { plannerPrompt } from "./prompt.js"; import { plannerPrompt } from "./prompt.js";
import { z } from "zod";
const roleSchema = z
.object({
name: z.string().default(""),
goal: z.string().default(""),
io: z.string().default(""),
})
.default({ name: "", goal: "", io: "" });
export const plannerMetaSchema = z.object({ export const plannerMetaSchema = z.object({
userPrompt: z.string().default(""), ready: z.boolean().describe("true if requirements are clear and a workflow can be implemented"),
workflowName: z
.string()
.default("")
.describe("kebab-case workflow name under workflows/, e.g. issue-fixer"),
roles: z.array(roleSchema).default([]),
flowTransitions: z.preprocess((v) => (Array.isArray(v) ? v.join("\n") : v), z.string().default("")),
validationLoopsDesign: z.preprocess(
(v) => (Array.isArray(v) ? v.join("\n") : v),
z.string().default(""),
),
externalDeps: z.preprocess(
(v) => (Array.isArray(v) ? v.join(", ") : v),
z.string().default(""),
),
dataFlow: z.preprocess((v) => (Array.isArray(v) ? v.join("\n") : v), z.string().default("")),
planMarkdown: z.preprocess(
(v) => (Array.isArray(v) ? v.join("\n") : v),
z.string().default(""),
),
}); });
export type PlannerMeta = z.infer<typeof plannerMetaSchema>; export type PlannerMeta = z.infer<typeof plannerMetaSchema>;
export type BuildPlannerDeps = { export type BuildPlannerDeps = {
provider: LlmProvider; provider: LlmProvider;
nerveRoot: string; cwd: string;
workflowsDir: string;
}; };
function getNerveYaml(nerveRoot: string): string { export function buildPlannerRole({ provider, cwd }: BuildPlannerDeps) {
const result = readNerveYaml({ nerveRoot }); return createCursorRole<PlannerMeta>({
return result.ok ? result.value : "# nerve.yaml unavailable"; cwd,
} mode: "ask",
prompt: async (threadId) => plannerPrompt({ threadId }),
function getSenseGeneratorReference(workflowsDir: string): string { extract: { provider, schema: plannerMetaSchema },
const p = join(workflowsDir, "sense-generator", "index.ts"); });
if (!existsSync(p)) {
return "(missing workflows/sense-generator/index.ts)";
}
return readFileSync(p, "utf-8");
}
export function buildPlannerRole({
provider,
nerveRoot,
workflowsDir,
}: BuildPlannerDeps): Role<PlannerMeta> {
return async (start, _messages) => {
const dry = isDryRun(start);
const userPrompt = start.content;
const messages = plannerPrompt({
nerveAgentContext,
userPrompt,
nerveRoot,
workflowsDir,
senseGeneratorReference: getSenseGeneratorReference(workflowsDir),
nerveYaml: getNerveYaml(nerveRoot),
});
const extracted = await llmExtract({
text: messages.map((m) => m.content).join("\n"),
schema: plannerMetaSchema,
provider,
dryRun: dry,
});
const emptyMeta: PlannerMeta = {
userPrompt,
workflowName: "",
roles: [],
flowTransitions: "",
validationLoopsDesign: "",
externalDeps: "",
dataFlow: "",
planMarkdown: "",
};
if (!extracted.ok) {
return {
content: `[planner] llmExtract failed: ${JSON.stringify(extracted.error)}`,
meta: emptyMeta,
} satisfies RoleResult<PlannerMeta>;
}
const value = extracted.value;
const planMarkdown =
value.planMarkdown.length > 0
? value.planMarkdown
: [
`# Workflow Plan`,
`- workflowName: ${value.workflowName}`,
``,
`## Roles`,
...value.roles.map((r) => `- ${r.name}: ${r.goal} (${r.io})`),
``,
`## Flow Transitions`,
value.flowTransitions,
``,
`## Validation Loops`,
value.validationLoopsDesign,
``,
`## External Dependencies`,
value.externalDeps,
``,
`## Data Flow`,
value.dataFlow,
].join("\n");
return {
content: planMarkdown,
meta: {
userPrompt,
workflowName: value.workflowName,
roles: value.roles,
flowTransitions: value.flowTransitions,
validationLoopsDesign: value.validationLoopsDesign,
externalDeps: value.externalDeps,
dataFlow: value.dataFlow,
planMarkdown,
},
} satisfies RoleResult<PlannerMeta>;
};
} }

View File

@ -1,49 +1,31 @@
import type { LlmMessage } from "@uncaged/nerve-workflow-utils"; export function plannerPrompt({ threadId }: { threadId: string }): string {
return `You are planning a new Nerve workflow.
export type PlannerPromptParams = { Read the workflow thread for the user's request: \`nerve thread ${threadId}\`
nerveAgentContext: string; Read the nerve-dev skill for workflow conventions: \`cat node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\`
userPrompt: string; Also look at existing workflows in the \`workflows/\` directory for patterns.
nerveRoot: string;
workflowsDir: string;
senseGeneratorReference: string;
nerveYaml: string;
};
export function plannerPrompt({ Assess whether the requirements are clear enough to implement a workflow.
nerveAgentContext,
userPrompt,
nerveRoot,
workflowsDir,
senseGeneratorReference,
nerveYaml,
}: PlannerPromptParams): LlmMessage[] {
const content = `Design a Nerve workflow plan from this request.
${nerveAgentContext} If requirements ARE clear:
- Pick a good kebab-case name for this workflow.
- Produce a PLAN (not code) in markdown covering:
- Workflow name
- Roles list (name, purpose, tool)
- Flow transitions / moderator routing logic
- Validation loops design
- External dependencies
- Data flow between roles
User request: If requirements are NOT clear:
${userPrompt} - Describe what is missing or ambiguous.
Target root: ${nerveRoot} End your response with a JSON block:
Workflow dir root: ${workflowsDir} \`\`\`json
{ "ready": true }
Reference structure:
\`\`\`ts
${senseGeneratorReference.slice(0, 18_000)}
\`\`\` \`\`\`
or
Current nerve.yaml: \`\`\`json
\`\`\`yaml { "ready": false }
${nerveYaml} \`\`\``;
\`\`\`
Produce a complete markdown plan that includes:
- workflow name
- roles list
- flow/transitions
- validation loops design
- external deps
- data flow`;
return [{ role: "user", content }];
} }

View File

@ -1,153 +1,20 @@
import type { Role, RoleResult, WorkflowMessage } from "@uncaged/nerve-core"; import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import type { SpawnError } from "@uncaged/nerve-workflow-utils"; import { createHermesRole } from "@uncaged/nerve-workflow-utils";
import { cursorAgent, isDryRun } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
import type { CoderMeta } from "../coder/index.js";
import type { PlannerMeta } from "../planner/index.js";
import { testerPrompt } from "./prompt.js"; import { testerPrompt } from "./prompt.js";
import { z } from "zod";
export const testerMetaSchema = z.object({ export const testerMetaSchema = z.object({
workflowName: z.string().default(""), passed: z.boolean().describe("true if all validation checks passed"),
attempt: z.number().default(1),
passed: z.boolean().default(false),
dryRunLog: z.string().default(""),
reason: z.string().default(""),
}); });
export type TesterMeta = z.infer<typeof testerMetaSchema>; export type TesterMeta = z.infer<typeof testerMetaSchema>;
export type BuildTesterDeps = { export type BuildTesterDeps = {
nerveRoot: string; provider: LlmProvider;
}; };
function formatSpawnFailure(error: SpawnError): string { export function buildTesterRole({ provider }: BuildTesterDeps) {
if (error.kind === "spawn_failed") { return createHermesRole<TesterMeta>({
return error.message; prompt: async (threadId) => testerPrompt({ threadId }),
} extract: { provider, schema: testerMetaSchema },
if (error.kind === "timeout") { });
return `timeout stdout=${error.stdout.slice(0, 300)} stderr=${error.stderr.slice(0, 300)}`;
}
return `exit ${error.exitCode} stderr=${error.stderr.slice(0, 500)}`;
}
function lastMetaForRole<M>(messages: WorkflowMessage[], role: string): M | null {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === role) {
return messages[i].meta as M;
}
}
return null;
}
export function buildTesterRole({ nerveRoot }: BuildTesterDeps): Role<TesterMeta> {
return async (start, messages) => {
const dry = isDryRun(start);
const plannerMeta = lastMetaForRole<PlannerMeta>(messages, "planner");
const coderMeta = lastMetaForRole<CoderMeta>(messages, "coder");
const attempt = messages.filter((m) => m.role === "tester").length + 1;
if (plannerMeta === null || coderMeta === null) {
return {
content: "tester cannot continue: missing planner/coder output",
meta: {
workflowName: "",
attempt,
passed: false,
dryRunLog: "",
reason: "missing planner/coder output",
},
} satisfies RoleResult<TesterMeta>;
}
if (!coderMeta.lintPassed || !coderMeta.buildPassed) {
return {
content: "tester blocked: coder has not passed lint+build",
meta: {
workflowName: coderMeta.workflowName,
attempt,
passed: false,
dryRunLog: `${coderMeta.lintLog}\n\n${coderMeta.buildLog}`,
reason: "coder did not pass lint+build",
},
} satisfies RoleResult<TesterMeta>;
}
if (dry) {
return {
content: "PASS — dry-run mode",
meta: {
workflowName: coderMeta.workflowName,
attempt,
passed: true,
dryRunLog: "[dry-run] tester skipped external checks",
reason: "dry-run mode",
},
} satisfies RoleResult<TesterMeta>;
}
const prompt = testerPrompt({
workflowName: coderMeta.workflowName,
plannerSpec: {
roles: plannerMeta.roles,
flowTransitions: plannerMeta.flowTransitions,
validationLoopsDesign: plannerMeta.validationLoopsDesign,
externalDeps: plannerMeta.externalDeps,
dataFlow: plannerMeta.dataFlow,
},
coderOutput: coderMeta.cursorOutput,
nerveRoot,
});
const run = await cursorAgent({
prompt,
mode: "ask",
cwd: nerveRoot,
env: null,
timeoutMs: null,
dryRun: false,
});
if (!run.ok) {
return {
content: "tester agent failed",
meta: {
workflowName: coderMeta.workflowName,
attempt,
passed: false,
dryRunLog: "",
reason: `tester agent failed: ${formatSpawnFailure(run.error)}`,
},
} satisfies RoleResult<TesterMeta>;
}
const text = run.value.trim();
const pass = text.startsWith("PASS|");
const fail = text.startsWith("FAIL|");
if (!pass && !fail) {
return {
content: "tester format invalid",
meta: {
workflowName: coderMeta.workflowName,
attempt,
passed: false,
dryRunLog: text,
reason: "tester format invalid",
},
} satisfies RoleResult<TesterMeta>;
}
const parts = text.split("|");
const reason = parts[1] ?? "no reason";
const log = parts.slice(2).join("|").trim();
return {
content: `${pass ? "PASS" : "FAIL"}${reason}`,
meta: {
workflowName: coderMeta.workflowName,
attempt,
passed: pass,
dryRunLog: log,
reason,
},
} satisfies RoleResult<TesterMeta>;
};
} }

View File

@ -1,34 +1,27 @@
export type TesterPromptParams = { export function testerPrompt({ threadId }: { threadId: string }): string {
workflowName: string; return `You are testing a newly generated Nerve workflow end-to-end.
plannerSpec: object;
coderOutput: string;
nerveRoot: string;
};
export function testerPrompt({ Read the workflow thread for context: \`nerve thread ${threadId}\`
workflowName, Read the nerve-dev skill for expected file structure: \`cat node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\`
plannerSpec,
coderOutput,
nerveRoot: _nerveRoot,
}: TesterPromptParams): string {
return `You are testing a generated Nerve workflow by doing a dry-run review.
Workflow: ${workflowName} Get the workflow name from the thread (the planner's output).
Planner specification: Verify the full lifecycle:
${JSON.stringify(plannerSpec, null, 2)} 1. Check all required workflow files exist: index.ts, package.json, tsconfig.json
2. Check nerve.yaml has the workflow config
3. Run \`nerve workflow list\` — confirm the workflow appears
4. Run \`pnpm build\` inside the workflow directory — must succeed
5. Run \`nerve workflow dry-run <workflow-name>\` — should complete without error
6. If any step fails, include the relevant error output
Coder output summary: Output a clear summary: what you checked, what passed, what failed, and why.
${coderOutput.slice(0, 6000)}
Required checks: End your response with a JSON block:
1) Verify role transitions are coherent and terminates to END. \`\`\`json
2) Verify generated workflow adheres to planner intent. { "passed": true }
3) Verify retry loops are explicit for recoverable failures. \`\`\`
4) Verify no obvious runtime-breaking issue in generated index.ts.
Return exactly:
PASS|<reason>|<compact markdown log>
or or
FAIL|<reason>|<compact markdown log>`; \`\`\`json
{ "passed": false }
\`\`\``;
} }