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,
nerveRoot,
}: BuildWorkflowGeneratorDeps): WorkflowDefinition<WorkflowMeta> {
const workflowsDir = join(nerveRoot, "workflows");
return {
name: "workflow-generator",
roles: {
planner: buildPlannerRole({ provider, nerveRoot, workflowsDir }),
coder: buildCoderRole({ nerveRoot, workflowsDir }),
tester: buildTesterRole({ nerveRoot }),
planner: buildPlannerRole({ provider, cwd: nerveRoot }),
coder: buildCoderRole({ provider, cwd: nerveRoot }),
tester: buildTesterRole({ provider }),
committer: buildCommitterRole({ nerveRoot }),
},
moderator,

View File

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

View File

@ -1,254 +1,23 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
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 type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { createCursorRole } from "@uncaged/nerve-workflow-utils";
import { coderPrompt } from "./prompt.js";
import { z } from "zod";
export const coderMetaSchema = z.object({
workflowName: z.string().default(""),
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),
done: z.boolean().describe("true if the workflow files were created and build passes"),
});
export type CoderMeta = z.infer<typeof coderMetaSchema>;
export type BuildCoderDeps = {
nerveRoot: string;
workflowsDir: string;
provider: LlmProvider;
cwd: string;
};
function formatSpawnFailure(error: SpawnError): string {
if (error.kind === "spawn_failed") {
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 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,
export function buildCoderRole({ provider, cwd }: BuildCoderDeps) {
return createCursorRole<CoderMeta>({
cwd,
mode: "default",
cwd: nerveRoot,
env: null,
timeoutMs: null,
dryRun: dry,
prompt: async (threadId) => coderPrompt({ threadId }),
extract: { provider, schema: coderMetaSchema },
});
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 = {
workflowsDir: string;
wfName: string;
planMarkdown: string;
plannerStructured: object;
feedback: string;
nerveRoot: string;
};
export function coderPrompt({ threadId }: { threadId: string }): string {
return `Read the workflow thread to get the planner's design and any tester feedback: \`nerve thread ${threadId}\`
Read the nerve-dev skill for workflow file structure and conventions: \`cat node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\`
Also look at existing workflows in the \`workflows/\` directory for patterns.
export function coderPrompt({
workflowsDir,
wfName,
planMarkdown,
plannerStructured,
feedback,
nerveRoot,
}: CoderPromptParams): string {
return `Implement a Nerve workflow package under ${workflowsDir}/${wfName}/.
Implement the workflow the planner designed. If there is tester feedback in the thread, fix the issues it identified.
Planner output:
${planMarkdown}
Structured planner fields:
${JSON.stringify(plannerStructured, null, 2)}
${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}
Required files for each workflow:
- \`workflows/<name>/index.ts\` — WorkflowDefinition default export
- \`workflows/<name>/package.json\` — with esbuild build script
- \`workflows/<name>/tsconfig.json\` — TypeScript config
- Update \`nerve.yaml\` with \`workflows.<name>\`
Rules:
- keep WorkflowDefinition<WorkflowMeta> pattern
- no dynamic import()
- use types (not interfaces)
- include retry-aware moderator routing
- write compile-ready TypeScript`;
- Keep the WorkflowDefinition<WorkflowMeta> pattern
- No dynamic import()
- Use types (not interfaces)
- Include retry-aware moderator routing
- 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 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 { committerPrompt } from "./prompt.js";
import { writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import type { Role, RoleResult } from "@uncaged/nerve-core";
import { isDryRun, spawnSafe } from "@uncaged/nerve-workflow-utils";
export const committerMetaSchema = z.object({
invoked: z.boolean().default(false),
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 CommitterMeta = {
success: boolean;
};
export type BuildCommitterDeps = {
nerveRoot: string;
};
function formatSpawnFailure(error: SpawnError): string {
if (error.kind === "spawn_failed") {
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 logPath(nerveRoot: string): string {
return join(nerveRoot, "logs", `committer-${Date.now()}.log`);
}
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;
}
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,
};
function writeLog(path: string, content: string): void {
mkdirSync(join(path, ".."), { recursive: true });
writeFileSync(path, content, "utf-8");
}
export function buildCommitterRole({ nerveRoot }: BuildCommitterDeps): Role<CommitterMeta> {
return async (start, messages) => {
return async (start, _messages) => {
const dry = isDryRun(start);
const planner = lastMetaForRole<PlannerMeta>(messages, "planner");
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>;
}
const file = logPath(nerveRoot);
if (dry) {
writeLog(file, "[dry-run] committer skipped\n");
return {
content: "[dry-run] skipped hermes committer",
meta: {
invoked: true,
success: true,
branch: "wf/dry-run",
commitHash: null,
pushed: null,
log: "[dry-run] skipped hermes committer",
error: null,
},
content: `[dry-run] committer skipped — log: ${file}`,
meta: { success: true },
} satisfies RoleResult<CommitterMeta>;
}
const task = committerPrompt({
nerveRoot,
workflowName,
userPrompt: planner.userPrompt,
testerReason: tester.reason,
});
const lines: string[] = [];
let success = true;
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 {
content: committed.success
? committed.log
: `committer failed: ${committed.error ?? "unknown"}`,
meta: committed,
content: `committer: ${summary}\nLog: ${file}`,
meta: { success },
} 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 { isDryRun, llmExtract, nerveAgentContext, readNerveYaml } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
import { createCursorRole } from "@uncaged/nerve-workflow-utils";
import { plannerPrompt } from "./prompt.js";
const roleSchema = z
.object({
name: z.string().default(""),
goal: z.string().default(""),
io: z.string().default(""),
})
.default({ name: "", goal: "", io: "" });
import { z } from "zod";
export const plannerMetaSchema = z.object({
userPrompt: z.string().default(""),
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(""),
),
ready: z.boolean().describe("true if requirements are clear and a workflow can be implemented"),
});
export type PlannerMeta = z.infer<typeof plannerMetaSchema>;
export type BuildPlannerDeps = {
provider: LlmProvider;
nerveRoot: string;
workflowsDir: string;
cwd: string;
};
function getNerveYaml(nerveRoot: string): string {
const result = readNerveYaml({ nerveRoot });
return result.ok ? result.value : "# nerve.yaml unavailable";
}
function getSenseGeneratorReference(workflowsDir: string): string {
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),
export function buildPlannerRole({ provider, cwd }: BuildPlannerDeps) {
return createCursorRole<PlannerMeta>({
cwd,
mode: "ask",
prompt: async (threadId) => plannerPrompt({ threadId }),
extract: { provider, schema: plannerMetaSchema },
});
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 = {
nerveAgentContext: string;
userPrompt: string;
nerveRoot: string;
workflowsDir: string;
senseGeneratorReference: string;
nerveYaml: string;
};
Read the workflow thread for the user's request: \`nerve thread ${threadId}\`
Read the nerve-dev skill for workflow conventions: \`cat node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\`
Also look at existing workflows in the \`workflows/\` directory for patterns.
export function plannerPrompt({
nerveAgentContext,
userPrompt,
nerveRoot,
workflowsDir,
senseGeneratorReference,
nerveYaml,
}: PlannerPromptParams): LlmMessage[] {
const content = `Design a Nerve workflow plan from this request.
Assess whether the requirements are clear enough to implement a workflow.
${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:
${userPrompt}
If requirements are NOT clear:
- Describe what is missing or ambiguous.
Target root: ${nerveRoot}
Workflow dir root: ${workflowsDir}
Reference structure:
\`\`\`ts
${senseGeneratorReference.slice(0, 18_000)}
End your response with a JSON block:
\`\`\`json
{ "ready": true }
\`\`\`
Current nerve.yaml:
\`\`\`yaml
${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 }];
or
\`\`\`json
{ "ready": false }
\`\`\``;
}

View File

@ -1,153 +1,20 @@
import type { Role, RoleResult, WorkflowMessage } from "@uncaged/nerve-core";
import type { SpawnError } 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 type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { createHermesRole } from "@uncaged/nerve-workflow-utils";
import { testerPrompt } from "./prompt.js";
import { z } from "zod";
export const testerMetaSchema = z.object({
workflowName: z.string().default(""),
attempt: z.number().default(1),
passed: z.boolean().default(false),
dryRunLog: z.string().default(""),
reason: z.string().default(""),
passed: z.boolean().describe("true if all validation checks passed"),
});
export type TesterMeta = z.infer<typeof testerMetaSchema>;
export type BuildTesterDeps = {
nerveRoot: string;
provider: LlmProvider;
};
function formatSpawnFailure(error: SpawnError): string {
if (error.kind === "spawn_failed") {
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 {
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,
export function buildTesterRole({ provider }: BuildTesterDeps) {
return createHermesRole<TesterMeta>({
prompt: async (threadId) => testerPrompt({ threadId }),
extract: { provider, schema: testerMetaSchema },
});
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 = {
workflowName: string;
plannerSpec: object;
coderOutput: string;
nerveRoot: string;
};
export function testerPrompt({ threadId }: { threadId: string }): string {
return `You are testing a newly generated Nerve workflow end-to-end.
export function testerPrompt({
workflowName,
plannerSpec,
coderOutput,
nerveRoot: _nerveRoot,
}: TesterPromptParams): string {
return `You are testing a generated Nerve workflow by doing a dry-run review.
Read the workflow thread for context: \`nerve thread ${threadId}\`
Read the nerve-dev skill for expected file structure: \`cat node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\`
Workflow: ${workflowName}
Get the workflow name from the thread (the planner's output).
Planner specification:
${JSON.stringify(plannerSpec, null, 2)}
Verify the full lifecycle:
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:
${coderOutput.slice(0, 6000)}
Output a clear summary: what you checked, what passed, what failed, and why.
Required checks:
1) Verify role transitions are coherent and terminates to END.
2) Verify generated workflow adheres to planner intent.
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>
End your response with a JSON block:
\`\`\`json
{ "passed": true }
\`\`\`
or
FAIL|<reason>|<compact markdown log>`;
\`\`\`json
{ "passed": false }
\`\`\``;
}