refactor(workflow-generator): multi-file DIP + Role Factory + esbuild bundle

- Split 500-line monolith into roles/{planner,coder,tester,committer}/
- Each role: index.ts (build function) + prompt.ts (pure function)
- Use createCursorRole/createLlmRole/createHermesRole factories
- DIP: env vars read in index.ts, injected via build.ts
- esbuild bundle to dist/index.js (24kb)
- Moderator logic preserved: planner→coder→tester→committer with retries

Fixes xiaoju/nerve-workspace#3
This commit is contained in:
小橘 2026-04-28 08:48:23 +00:00
parent 4cf10ad7bf
commit a469f30b42
14 changed files with 2103 additions and 797 deletions

View File

@ -0,0 +1,31 @@
import { join } from "node:path";
import type { WorkflowDefinition } from "@uncaged/nerve-core";
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
import { buildPlannerRole } from "./roles/planner/index.js";
import { buildCoderRole } from "./roles/coder/index.js";
import { buildTesterRole } from "./roles/tester/index.js";
import { buildCommitterRole } from "./roles/committer/index.js";
import { moderator } from "./moderator.js";
import type { WorkflowMeta } from "./moderator.js";
export type BuildWorkflowGeneratorDeps = {
provider: LlmProvider;
nerveRoot: string;
};
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 }),
committer: buildCommitterRole({ nerveRoot }),
},
moderator,
};
}

View File

@ -0,0 +1,846 @@
// index.ts
import { join as join4 } from "node:path";
// build.ts
import { join as join3 } from "node:path";
// roles/planner/index.ts
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { isDryRun, llmExtract, nerveAgentContext, readNerveYaml } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
// roles/planner/prompt.ts
function plannerPrompt({
nerveAgentContext: nerveAgentContext2,
userPrompt,
nerveRoot,
workflowsDir,
senseGeneratorReference,
nerveYaml
}) {
const content = `Design a Nerve workflow plan from this request.
${nerveAgentContext2}
User request:
${userPrompt}
Target root: ${nerveRoot}
Workflow dir root: ${workflowsDir}
Reference structure:
\`\`\`ts
${senseGeneratorReference.slice(0, 18e3)}
\`\`\`
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 }];
}
// roles/planner/index.ts
var roleSchema = z.object({
name: z.string().default(""),
goal: z.string().default(""),
io: z.string().default("")
}).default({ name: "", goal: "", io: "" });
var 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("")
)
});
function getNerveYaml(nerveRoot) {
const result = readNerveYaml({ nerveRoot });
return result.ok ? result.value : "# nerve.yaml unavailable";
}
function getSenseGeneratorReference(workflowsDir) {
const p = join(workflowsDir, "sense-generator", "index.ts");
if (!existsSync(p)) {
return "(missing workflows/sense-generator/index.ts)";
}
return readFileSync(p, "utf-8");
}
function buildPlannerRole({
provider,
nerveRoot,
workflowsDir
}) {
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 = {
userPrompt,
workflowName: "",
roles: [],
flowTransitions: "",
validationLoopsDesign: "",
externalDeps: "",
dataFlow: "",
planMarkdown: ""
};
if (!extracted.ok) {
return {
content: `[planner] llmExtract failed: ${JSON.stringify(extracted.error)}`,
meta: emptyMeta
};
}
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
}
};
};
}
// roles/coder/index.ts
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
import { join as join2 } from "node:path";
import { cursorAgent, isDryRun as isDryRun2, spawnSafe } from "@uncaged/nerve-workflow-utils";
import { z as z2 } from "zod";
// roles/coder/prompt.ts
function coderPrompt({
workflowsDir,
wfName,
planMarkdown,
plannerStructured,
feedback,
nerveRoot
}) {
return `Implement a Nerve workflow package under ${workflowsDir}/${wfName}/.
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}
Rules:
- keep WorkflowDefinition<WorkflowMeta> pattern
- no dynamic import()
- use types (not interfaces)
- include retry-aware moderator routing
- write compile-ready TypeScript`;
}
// roles/coder/index.ts
var coderMetaSchema = z2.object({
workflowName: z2.string().default(""),
attempt: z2.number().default(1),
files: z2.object({
indexTs: z2.boolean().default(false),
packageJson: z2.boolean().default(false),
tsconfigJson: z2.boolean().default(false)
}).default({ indexTs: false, packageJson: false, tsconfigJson: false }),
lintPassed: z2.boolean().default(false),
buildPassed: z2.boolean().default(false),
lintLog: z2.string().default(""),
buildLog: z2.string().default(""),
cursorOutput: z2.string().default(""),
reason: z2.string().nullable().default(null)
});
function formatSpawnFailure(error) {
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) {
const issues = [];
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, dry) {
const lintRun = await spawnSafe("pnpm", ["run", "check"], {
cwd: workflowDir,
env: null,
timeoutMs: 3e5,
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: 3e5,
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(messages, role) {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === role) {
return messages[i].meta;
}
}
return null;
}
function buildCoderRole({ nerveRoot, workflowsDir }) {
return async (start, messages) => {
const dry = isDryRun2(start);
const plannerMeta = lastMetaForRole(messages, "planner");
const previousTester = lastMetaForRole(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"
}
};
}
const wfName = plannerMeta.workflowName.trim();
const feedback = previousTester !== null && previousTester.passed === false ? `
Previous tester failure to fix:
${previousTester.reason}
${previousTester.dryRunLog}
` : "";
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 = join2(workflowsDir, wfName);
const files = {
indexTs: existsSync2(join2(workflowDir, "index.ts")),
packageJson: existsSync2(join2(workflowDir, "package.json")),
tsconfigJson: existsSync2(join2(workflowDir, "tsconfig.json"))
};
const missing = [
files.indexTs ? null : "index.ts",
files.packageJson ? null : "package.json",
files.tsconfigJson ? null : "tsconfig.json"
].filter((x) => x !== null);
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)
}
};
}
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(", ")}`
}
};
}
const source = readFileSync2(join2(workflowDir, "index.ts"), "utf-8");
const pitfalls = scanGeneratedCodePitfalls(source);
if (pitfalls.length > 0) {
return {
content: `coder static check failed:
${pitfalls.join("\n")}`,
meta: {
workflowName: wfName,
attempt,
files,
lintPassed: false,
buildPassed: false,
lintLog: pitfalls.join("\n"),
buildLog: "",
cursorOutput: agentRun.value,
reason: pitfalls.join("; ")
}
};
}
const check = await runLintAndBuild(workflowDir, dry);
const passed = check.lintPassed && check.buildPassed;
return {
content: passed ? `coder PASS: lint+build ok
${check.lintLog}
${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
}
};
};
}
// roles/tester/index.ts
import { cursorAgent as cursorAgent2, isDryRun as isDryRun3 } from "@uncaged/nerve-workflow-utils";
import { z as z3 } from "zod";
// roles/tester/prompt.ts
function testerPrompt({
workflowName,
plannerSpec,
coderOutput,
nerveRoot: _nerveRoot
}) {
return `You are testing a generated Nerve workflow by doing a dry-run review.
Workflow: ${workflowName}
Planner specification:
${JSON.stringify(plannerSpec, null, 2)}
Coder output summary:
${coderOutput.slice(0, 6e3)}
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>
or
FAIL|<reason>|<compact markdown log>`;
}
// roles/tester/index.ts
var testerMetaSchema = z3.object({
workflowName: z3.string().default(""),
attempt: z3.number().default(1),
passed: z3.boolean().default(false),
dryRunLog: z3.string().default(""),
reason: z3.string().default("")
});
function formatSpawnFailure2(error) {
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 lastMetaForRole2(messages, role) {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === role) {
return messages[i].meta;
}
}
return null;
}
function buildTesterRole({ nerveRoot }) {
return async (start, messages) => {
const dry = isDryRun3(start);
const plannerMeta = lastMetaForRole2(messages, "planner");
const coderMeta = lastMetaForRole2(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"
}
};
}
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}
${coderMeta.buildLog}`,
reason: "coder did not pass lint+build"
}
};
}
if (dry) {
return {
content: "PASS \u2014 dry-run mode",
meta: {
workflowName: coderMeta.workflowName,
attempt,
passed: true,
dryRunLog: "[dry-run] tester skipped external checks",
reason: "dry-run mode"
}
};
}
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 cursorAgent2({
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: ${formatSpawnFailure2(run.error)}`
}
};
}
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"
}
};
}
const parts = text.split("|");
const reason = parts[1] ?? "no reason";
const log = parts.slice(2).join("|").trim();
return {
content: `${pass ? "PASS" : "FAIL"} \u2014 ${reason}`,
meta: {
workflowName: coderMeta.workflowName,
attempt,
passed: pass,
dryRunLog: log,
reason
}
};
};
}
// roles/committer/index.ts
import { cursorAgent as cursorAgent3, isDryRun as isDryRun4, spawnSafe as spawnSafe2 } from "@uncaged/nerve-workflow-utils";
import { z as z4 } from "zod";
// roles/committer/prompt.ts
function committerPrompt({
nerveRoot,
workflowName,
userPrompt,
testerReason
}) {
return `You are a git committer subagent for Nerve workflow generation.
Repository root: ${nerveRoot}
Goal:
- Commit and push generated workflow "${workflowName}".
- Handle dirty worktree safely (do not discard unrelated user edits).
- Detect default branch automatically.
- Create a focused branch for this workflow update.
- Stage only workflow files and required config updates.
Context:
- User prompt summary: ${userPrompt.slice(0, 500)}
- Tester result: ${testerReason}
Expected output format:
BRANCH=<branch-or-empty>
COMMIT=<hash-or-empty>
PUSHED=<true|false|unknown>
LOG_START
<details>
LOG_END`;
}
// roles/committer/index.ts
var committerMetaSchema = z4.object({
invoked: z4.boolean().default(false),
success: z4.boolean().default(false),
branch: z4.string().nullable().default(null),
commitHash: z4.string().nullable().default(null),
pushed: z4.boolean().nullable().default(null),
log: z4.string().default(""),
error: z4.string().nullable().default(null)
});
function formatSpawnFailure3(error) {
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 lastMetaForRole3(messages, role) {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === role) {
return messages[i].meta;
}
}
return null;
}
function inferWorkflowName(messages) {
const tester = lastMetaForRole3(messages, "tester");
if (tester !== null && tester.workflowName.trim().length > 0) {
return tester.workflowName.trim();
}
const coder = lastMetaForRole3(messages, "coder");
if (coder !== null && coder.workflowName.trim().length > 0) {
return coder.workflowName.trim();
}
const planner = lastMetaForRole3(messages, "planner");
if (planner !== null && planner.workflowName.trim().length > 0) {
return planner.workflowName.trim();
}
return "";
}
async function runHermesCommitter(task, nerveRoot) {
const commandAttempts = [
{ cmd: "hermes-agent", args: ["--cwd", nerveRoot, "--task", task] },
{ cmd: "hermes", args: ["agent", "--cwd", nerveRoot, "--task", task] }
];
for (const candidate of commandAttempts) {
const run = await spawnSafe2(candidate.cmd, candidate.args, {
cwd: nerveRoot,
env: null,
timeoutMs: 6e5,
dryRun: false
});
if (!run.ok) {
continue;
}
const text = `${run.value.stdout}
${run.value.stderr}`;
const branch2 = text.match(/^BRANCH=(.*)$/m)?.[1]?.trim() ?? null;
const commitHash2 = 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: branch2 && branch2.length > 0 ? branch2 : null,
commitHash: commitHash2 && commitHash2.length > 0 ? commitHash2 : null,
pushed,
log: text.slice(0, 2e4),
error: null
};
}
const fallback = await cursorAgent3({
prompt: `Run this git committer task in repository ${nerveRoot}:
${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: ${formatSpawnFailure3(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, 2e4),
error: null
};
}
function buildCommitterRole({ nerveRoot }) {
return async (start, messages) => {
const dry = isDryRun4(start);
const planner = lastMetaForRole3(messages, "planner");
const tester = lastMetaForRole3(messages, "tester");
const workflowName = inferWorkflowName(messages);
const skipMeta = {
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" }
};
}
if (!tester.passed) {
return {
content: "committer skipped: tester not passed",
meta: { ...skipMeta, error: "tester not passed" }
};
}
if (dry) {
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
}
};
}
const task = committerPrompt({
nerveRoot,
workflowName,
userPrompt: planner.userPrompt,
testerReason: tester.reason
});
const committed = await runHermesCommitter(task, nerveRoot);
return {
content: committed.success ? committed.log : `committer failed: ${committed.error ?? "unknown"}`,
meta: committed
};
};
}
// moderator.ts
import { END } from "@uncaged/nerve-core";
var moderator = (context) => {
if (context.steps.length === 0) {
return "planner";
}
const last = context.steps[context.steps.length - 1];
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;
}
if (last.role === "coder") {
if (last.meta.lintPassed && last.meta.buildPassed) {
return "tester";
}
if (last.meta.attempt < 3) {
return "coder";
}
return END;
}
if (last.role === "tester") {
if (last.meta.passed) {
return "committer";
}
if (last.meta.attempt < 3) {
return "coder";
}
return END;
}
return END;
};
// build.ts
function buildWorkflowGenerator({
provider,
nerveRoot
}) {
const workflowsDir = join3(nerveRoot, "workflows");
return {
name: "workflow-generator",
roles: {
planner: buildPlannerRole({ provider, nerveRoot, workflowsDir }),
coder: buildCoderRole({ nerveRoot, workflowsDir }),
tester: buildTesterRole({ nerveRoot }),
committer: buildCommitterRole({ nerveRoot })
},
moderator
};
}
// index.ts
var HOME = process.env.HOME ?? "/home/azureuser";
var NERVE_ROOT = join4(HOME, ".uncaged-nerve");
var apiKey = process.env.DASHSCOPE_API_KEY;
var baseUrl = process.env.DASHSCOPE_BASE_URL;
var model = process.env.DASHSCOPE_MODEL ?? "qwen-plus";
if (!apiKey || !baseUrl) {
throw new Error("Set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL");
}
var workflow = buildWorkflowGenerator({
provider: { apiKey, baseUrl, model },
nerveRoot: NERVE_ROOT
});
var index_default = workflow;
export {
index_default as default
};

View File

@ -1,807 +1,20 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import type { RoleResult, StartStep, WorkflowDefinition, WorkflowMessage } from "@uncaged/nerve-core"; import { buildWorkflowGenerator } from "./build.js";
import { END } from "@uncaged/nerve-core";
import type { SpawnError } from "@uncaged/nerve-workflow-utils";
import {
cursorAgent,
isDryRun,
llmExtract,
nerveAgentContext,
readNerveYaml,
spawnSafe,
} from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
const HOME = process.env.HOME ?? "/home/azureuser"; const HOME = process.env.HOME ?? "/home/azureuser";
const NERVE_ROOT = join(HOME, ".uncaged-nerve"); const NERVE_ROOT = join(HOME, ".uncaged-nerve");
const WORKFLOWS_DIR = join(NERVE_ROOT, "workflows");
type PlannerRole = { const apiKey = process.env.DASHSCOPE_API_KEY;
name: string; const baseUrl = process.env.DASHSCOPE_BASE_URL;
goal: string; const model = process.env.DASHSCOPE_MODEL ?? "qwen-plus";
io: string;
};
type WorkflowMeta = {
planner: {
userPrompt: string;
workflowName: string;
roles: PlannerRole[];
flowTransitions: string;
validationLoopsDesign: string;
externalDeps: string;
dataFlow: string;
planMarkdown: string;
};
coder: {
workflowName: string;
attempt: number;
files: { indexTs: boolean; packageJson: boolean; tsconfigJson: boolean };
lintPassed: boolean;
buildPassed: boolean;
lintLog: string;
buildLog: string;
cursorOutput: string;
reason: string | null;
};
tester: {
workflowName: string;
attempt: number;
passed: boolean;
dryRunLog: string;
reason: string;
};
committer: {
invoked: boolean;
success: boolean;
branch: string | null;
commitHash: string | null;
pushed: boolean | null;
log: string;
error: string | null;
};
};
const roleSchema = z
.object({
name: z.string().default(""),
goal: z.string().default(""),
io: z.string().default(""),
})
.default({ name: "", goal: "", io: "" });
const plannerExtractSchema = z.object({
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("")),
});
function getNerveYaml(): string {
const result = readNerveYaml({ nerveRoot: NERVE_ROOT });
return result.ok ? result.value : "# nerve.yaml unavailable";
}
function buildSenseGeneratorReference(): string {
const p = join(WORKFLOWS_DIR, "sense-generator", "index.ts");
if (!existsSync(p)) {
return "(missing workflows/sense-generator/index.ts)";
}
return readFileSync(p, "utf-8");
}
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)}`;
}
async function cfgGet(key: string): Promise<string | null> {
const result = await spawnSafe("cfg", ["get", key], {
cwd: NERVE_ROOT,
env: null,
timeoutMs: 10_000,
});
if (!result.ok) {
return null;
}
const v = result.value.stdout.trim();
return v.length > 0 ? v : null;
}
async function resolveDashScopeProvider(): Promise<{
baseUrl: string;
apiKey: string;
model: string;
} | null> {
const apiKey = process.env.DASHSCOPE_API_KEY ?? (await cfgGet("DASHSCOPE_API_KEY"));
const baseUrl = process.env.DASHSCOPE_BASE_URL ?? (await cfgGet("DASHSCOPE_BASE_URL"));
const model = process.env.DASHSCOPE_MODEL ?? (await cfgGet("DASHSCOPE_MODEL")) ?? "qwen-plus";
if (!apiKey || !baseUrl) { if (!apiKey || !baseUrl) {
return null; throw new Error("Set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL");
}
return { apiKey, baseUrl, model };
} }
function lastMetaForRole<M>(messages: WorkflowMessage[], role: string): M | null { const workflow = buildWorkflowGenerator({
for (let i = messages.length - 1; i >= 0; i--) { provider: { apiKey, baseUrl, model },
if (messages[i].role === role) { nerveRoot: NERVE_ROOT,
return messages[i].meta as M;
}
}
return null;
}
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;
}
function inferWorkflowName(messages: WorkflowMessage[]): string {
const tester = lastMetaForRole<WorkflowMeta["tester"]>(messages, "tester");
if (tester !== null && tester.workflowName.trim().length > 0) {
return tester.workflowName.trim();
}
const coder = lastMetaForRole<WorkflowMeta["coder"]>(messages, "coder");
if (coder !== null && coder.workflowName.trim().length > 0) {
return coder.workflowName.trim();
}
const planner = lastMetaForRole<WorkflowMeta["planner"]>(messages, "planner");
if (planner !== null && planner.workflowName.trim().length > 0) {
return planner.workflowName.trim();
}
return "";
}
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 };
}
async function runTesterDryRun(
workflowName: string,
planner: WorkflowMeta["planner"],
coder: WorkflowMeta["coder"],
dry: boolean,
): Promise<{ passed: boolean; reason: string; log: string }> {
if (dry) {
return {
passed: true,
reason: "dry-run mode",
log: "[dry-run] tester skipped external checks",
};
}
const prompt = `You are testing a generated Nerve workflow by doing a dry-run review.
Workflow: ${workflowName}
Planner specification:
${JSON.stringify(
{
roles: planner.roles,
flowTransitions: planner.flowTransitions,
validationLoopsDesign: planner.validationLoopsDesign,
externalDeps: planner.externalDeps,
dataFlow: planner.dataFlow,
},
null,
2,
)}
Coder output summary:
${coder.cursorOutput.slice(0, 6000)}
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>
or
FAIL|<reason>|<compact markdown log>`;
const run = await cursorAgent({
prompt,
mode: "ask",
cwd: NERVE_ROOT,
env: null,
timeoutMs: null,
dryRun: false,
});
if (!run.ok) {
return {
passed: false,
reason: `tester agent failed: ${formatSpawnFailure(run.error)}`,
log: "",
};
}
const text = run.value.trim();
const pass = text.startsWith("PASS|");
const fail = text.startsWith("FAIL|");
if (!pass && !fail) {
return { passed: false, reason: "tester format invalid", log: text };
}
const parts = text.split("|");
const reason = parts[1] ?? "no reason";
const log = parts.slice(2).join("|").trim();
return { passed: pass, reason, log };
}
async function runHermesCommitter(
workflowName: string,
userPrompt: string,
testerReason: string,
dry: boolean,
): Promise<{
invoked: boolean;
success: boolean;
branch: string | null;
commitHash: string | null;
pushed: boolean | null;
log: string;
error: string | null;
}> {
const task = `You are a git committer subagent for Nerve workflow generation.
Repository root: ${NERVE_ROOT}
Goal:
- Commit and push generated workflow "${workflowName}".
- Handle dirty worktree safely (do not discard unrelated user edits).
- Detect default branch automatically.
- Create a focused branch for this workflow update.
- Stage only workflow files and required config updates.
Context:
- User prompt summary: ${userPrompt.slice(0, 500)}
- Tester result: ${testerReason}
Expected output format:
BRANCH=<branch-or-empty>
COMMIT=<hash-or-empty>
PUSHED=<true|false|unknown>
LOG_START
<details>
LOG_END`;
if (dry) {
return {
invoked: true,
success: true,
branch: "wf/dry-run",
commitHash: null,
pushed: null,
log: "[dry-run] skipped hermes committer",
error: null,
};
}
const commandAttempts: Array<{ cmd: string; args: string[] }> = [
{ cmd: "hermes-agent", args: ["--cwd", NERVE_ROOT, "--task", task] },
{ cmd: "hermes", args: ["agent", "--cwd", NERVE_ROOT, "--task", task] },
];
for (const candidate of commandAttempts) {
const run = await spawnSafe(candidate.cmd, candidate.args, {
cwd: NERVE_ROOT,
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 ${NERVE_ROOT}:\n\n${task}`,
mode: "default",
cwd: NERVE_ROOT,
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,
};
}
const workflow: WorkflowDefinition<WorkflowMeta> = {
name: "workflow-generator",
roles: {
async planner(
start: StartStep,
_messages: WorkflowMessage[],
): Promise<RoleResult<WorkflowMeta["planner"]>> {
const dry = isDryRun(start);
const provider = await resolveDashScopeProvider();
const userPrompt = start.content;
if (provider === null) {
return {
content: "Cannot run planner: missing DASHSCOPE_API_KEY or DASHSCOPE_BASE_URL.",
meta: {
userPrompt,
workflowName: "",
roles: [],
flowTransitions: "",
validationLoopsDesign: "",
externalDeps: "",
dataFlow: "",
planMarkdown: "",
},
};
}
const planningText = `Design a Nerve workflow plan from this request.
${nerveAgentContext}
User request:
${userPrompt}
Target root: ${NERVE_ROOT}
Workflow dir root: ${WORKFLOWS_DIR}
Reference structure:
\`\`\`ts
${buildSenseGeneratorReference().slice(0, 18_000)}
\`\`\`
Current nerve.yaml:
\`\`\`yaml
${getNerveYaml()}
\`\`\`
Produce a complete markdown plan that includes:
- workflow name
- roles list
- flow/transitions
- validation loops design
- external deps
- data flow`;
const extracted = await llmExtract({
text: planningText,
schema: plannerExtractSchema,
provider,
dryRun: dry,
});
if (!extracted.ok) {
return {
content: `[planner] llmExtract failed: ${JSON.stringify(extracted.error)}`,
meta: {
userPrompt,
workflowName: "",
roles: [],
flowTransitions: "",
validationLoopsDesign: "",
externalDeps: "",
dataFlow: "",
planMarkdown: "",
},
};
}
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,
},
};
},
async coder(start: StartStep, messages: WorkflowMessage[]): Promise<RoleResult<WorkflowMeta["coder"]>> {
const dry = isDryRun(start);
const plannerMeta = lastMetaForRole<WorkflowMeta["planner"]>(messages, "planner");
const previousTester = lastMetaForRole<WorkflowMeta["tester"]>(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",
},
};
}
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 codingPrompt = `Implement a Nerve workflow package under ${WORKFLOWS_DIR}/${wfName}/.
Planner output:
${plannerMeta.planMarkdown}
Structured planner fields:
${JSON.stringify(
{
workflowName: plannerMeta.workflowName,
roles: plannerMeta.roles,
flowTransitions: plannerMeta.flowTransitions,
validationLoopsDesign: plannerMeta.validationLoopsDesign,
externalDeps: plannerMeta.externalDeps,
dataFlow: plannerMeta.dataFlow,
},
null,
2,
)}
${feedback}
Required files:
1) ${WORKFLOWS_DIR}/${wfName}/index.ts
2) ${WORKFLOWS_DIR}/${wfName}/package.json
3) ${WORKFLOWS_DIR}/${wfName}/tsconfig.json
4) update ${NERVE_ROOT}/nerve.yaml with workflows.${wfName}
Rules:
- keep WorkflowDefinition<WorkflowMeta> pattern
- no dynamic import()
- use types (not interfaces)
- include retry-aware moderator routing
- write compile-ready TypeScript`;
const agentRun = await cursorAgent({
prompt: codingPrompt,
mode: "default",
cwd: NERVE_ROOT,
env: null,
timeoutMs: null,
dryRun: dry,
});
const workflowDir = join(WORKFLOWS_DIR, 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),
},
};
}
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(", ")}`,
},
};
}
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("; "),
},
};
}
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,
},
};
},
async tester(start: StartStep, messages: WorkflowMessage[]): Promise<RoleResult<WorkflowMeta["tester"]>> {
const dry = isDryRun(start);
const plannerMeta = lastMetaForRole<WorkflowMeta["planner"]>(messages, "planner");
const coderMeta = lastMetaForRole<WorkflowMeta["coder"]>(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",
},
};
}
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",
},
};
}
const dryRun = await runTesterDryRun(coderMeta.workflowName, plannerMeta, coderMeta, dry);
return {
content: `${dryRun.passed ? "PASS" : "FAIL"}${dryRun.reason}`,
meta: {
workflowName: coderMeta.workflowName,
attempt,
passed: dryRun.passed,
dryRunLog: dryRun.log,
reason: dryRun.reason,
},
};
},
async committer(
start: StartStep,
messages: WorkflowMessage[],
): Promise<RoleResult<WorkflowMeta["committer"]>> {
const dry = isDryRun(start);
const planner = lastMetaForRole<WorkflowMeta["planner"]>(messages, "planner");
const tester = lastMetaForRole<WorkflowMeta["tester"]>(messages, "tester");
const workflowName = inferWorkflowName(messages);
if (planner === null || tester === null || workflowName.length === 0) {
return {
content: "committer skipped: missing planner/tester/workflowName context",
meta: {
invoked: false,
success: false,
branch: null,
commitHash: null,
pushed: null,
log: "",
error: "missing committer context",
},
};
}
if (!tester.passed) {
return {
content: "committer skipped: tester not passed",
meta: {
invoked: false,
success: false,
branch: null,
commitHash: null,
pushed: null,
log: "",
error: "tester not passed",
},
};
}
const committed = await runHermesCommitter(
workflowName,
planner.userPrompt,
tester.reason,
dry,
);
return {
content: committed.success ? committed.log : `committer failed: ${committed.error ?? "unknown"}`,
meta: committed,
};
},
},
moderator(context) {
if (context.steps.length === 0) {
return "planner";
}
const last = context.steps[context.steps.length - 1];
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;
}
if (last.role === "coder") {
if (last.meta.lintPassed && last.meta.buildPassed) {
return "tester";
}
if (last.meta.attempt < 3) {
return "coder";
}
return END;
}
if (last.role === "tester") {
if (last.meta.passed) {
return "committer";
}
if (last.meta.attempt < 3) {
return "coder";
}
return END;
}
return END;
},
};
export default workflow; export default workflow;

View File

@ -0,0 +1,45 @@
import { END } from "@uncaged/nerve-core";
import type { Moderator } from "@uncaged/nerve-core";
import type { PlannerMeta } from "./roles/planner/index.js";
import type { CoderMeta } from "./roles/coder/index.js";
import type { TesterMeta } from "./roles/tester/index.js";
import type { CommitterMeta } from "./roles/committer/index.js";
export type WorkflowMeta = {
planner: PlannerMeta;
coder: CoderMeta;
tester: TesterMeta;
committer: CommitterMeta;
};
export const moderator: Moderator<WorkflowMeta> = (context) => {
if (context.steps.length === 0) {
return "planner";
}
const last = context.steps[context.steps.length - 1];
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;
}
if (last.role === "coder") {
if (last.meta.lintPassed && last.meta.buildPassed) {
return "tester";
}
if (last.meta.attempt < 3) {
return "coder";
}
return END;
}
if (last.role === "tester") {
if (last.meta.passed) {
return "committer";
}
if (last.meta.attempt < 3) {
return "coder";
}
return END;
}
return END;
};

View File

@ -3,6 +3,9 @@
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": {
"build": "esbuild index.ts --bundle --platform=node --format=esm --outdir=dist --packages=external"
},
"dependencies": { "dependencies": {
"@uncaged/nerve-core": "latest", "@uncaged/nerve-core": "latest",
"@uncaged/nerve-workflow-utils": "latest", "@uncaged/nerve-workflow-utils": "latest",
@ -10,6 +13,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"esbuild": "^0.27.0",
"typescript": "^5.7.0" "typescript": "^5.7.0"
}, },
"pnpm": { "pnpm": {

View File

@ -26,15 +26,179 @@ importers:
'@types/node': '@types/node':
specifier: ^22.0.0 specifier: ^22.0.0
version: 22.19.17 version: 22.19.17
esbuild:
specifier: ^0.27.0
version: 0.27.7
typescript: typescript:
specifier: ^5.7.0 specifier: ^5.7.0
version: 5.9.3 version: 5.9.3
packages: packages:
'@esbuild/aix-ppc64@0.27.7':
resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.7':
resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.7':
resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.7':
resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.7':
resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.7':
resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.7':
resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.7':
resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.7':
resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.7':
resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.7':
resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.7':
resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.7':
resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.7':
resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.7':
resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.7':
resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.7':
resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.7':
resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.7':
resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.7':
resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.7':
resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.7':
resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.7':
resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.7':
resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.7':
resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.7':
resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@types/node@22.19.17': '@types/node@22.19.17':
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
esbuild@0.27.7:
resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
engines: {node: '>=18'}
hasBin: true
typescript@5.9.3: typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
@ -48,10 +212,117 @@ packages:
snapshots: snapshots:
'@esbuild/aix-ppc64@0.27.7':
optional: true
'@esbuild/android-arm64@0.27.7':
optional: true
'@esbuild/android-arm@0.27.7':
optional: true
'@esbuild/android-x64@0.27.7':
optional: true
'@esbuild/darwin-arm64@0.27.7':
optional: true
'@esbuild/darwin-x64@0.27.7':
optional: true
'@esbuild/freebsd-arm64@0.27.7':
optional: true
'@esbuild/freebsd-x64@0.27.7':
optional: true
'@esbuild/linux-arm64@0.27.7':
optional: true
'@esbuild/linux-arm@0.27.7':
optional: true
'@esbuild/linux-ia32@0.27.7':
optional: true
'@esbuild/linux-loong64@0.27.7':
optional: true
'@esbuild/linux-mips64el@0.27.7':
optional: true
'@esbuild/linux-ppc64@0.27.7':
optional: true
'@esbuild/linux-riscv64@0.27.7':
optional: true
'@esbuild/linux-s390x@0.27.7':
optional: true
'@esbuild/linux-x64@0.27.7':
optional: true
'@esbuild/netbsd-arm64@0.27.7':
optional: true
'@esbuild/netbsd-x64@0.27.7':
optional: true
'@esbuild/openbsd-arm64@0.27.7':
optional: true
'@esbuild/openbsd-x64@0.27.7':
optional: true
'@esbuild/openharmony-arm64@0.27.7':
optional: true
'@esbuild/sunos-x64@0.27.7':
optional: true
'@esbuild/win32-arm64@0.27.7':
optional: true
'@esbuild/win32-ia32@0.27.7':
optional: true
'@esbuild/win32-x64@0.27.7':
optional: true
'@types/node@22.19.17': '@types/node@22.19.17':
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
esbuild@0.27.7:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.7
'@esbuild/android-arm': 0.27.7
'@esbuild/android-arm64': 0.27.7
'@esbuild/android-x64': 0.27.7
'@esbuild/darwin-arm64': 0.27.7
'@esbuild/darwin-x64': 0.27.7
'@esbuild/freebsd-arm64': 0.27.7
'@esbuild/freebsd-x64': 0.27.7
'@esbuild/linux-arm': 0.27.7
'@esbuild/linux-arm64': 0.27.7
'@esbuild/linux-ia32': 0.27.7
'@esbuild/linux-loong64': 0.27.7
'@esbuild/linux-mips64el': 0.27.7
'@esbuild/linux-ppc64': 0.27.7
'@esbuild/linux-riscv64': 0.27.7
'@esbuild/linux-s390x': 0.27.7
'@esbuild/linux-x64': 0.27.7
'@esbuild/netbsd-arm64': 0.27.7
'@esbuild/netbsd-x64': 0.27.7
'@esbuild/openbsd-arm64': 0.27.7
'@esbuild/openbsd-x64': 0.27.7
'@esbuild/openharmony-arm64': 0.27.7
'@esbuild/sunos-x64': 0.27.7
'@esbuild/win32-arm64': 0.27.7
'@esbuild/win32-ia32': 0.27.7
'@esbuild/win32-x64': 0.27.7
typescript@5.9.3: {} typescript@5.9.3: {}
undici-types@6.21.0: {} undici-types@6.21.0: {}

View File

@ -0,0 +1,254 @@
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 { coderPrompt } from "./prompt.js";
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),
});
export type CoderMeta = z.infer<typeof coderMetaSchema>;
export type BuildCoderDeps = {
nerveRoot: string;
workflowsDir: 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,
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

@ -0,0 +1,39 @@
export type CoderPromptParams = {
workflowsDir: string;
wfName: string;
planMarkdown: string;
plannerStructured: object;
feedback: string;
nerveRoot: string;
};
export function coderPrompt({
workflowsDir,
wfName,
planMarkdown,
plannerStructured,
feedback,
nerveRoot,
}: CoderPromptParams): string {
return `Implement a Nerve workflow package under ${workflowsDir}/${wfName}/.
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}
Rules:
- keep WorkflowDefinition<WorkflowMeta> pattern
- no dynamic import()
- use types (not interfaces)
- include retry-aware moderator routing
- write compile-ready TypeScript`;
}

View File

@ -0,0 +1,190 @@
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";
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 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 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,
};
}
export function buildCommitterRole({ nerveRoot }: BuildCommitterDeps): Role<CommitterMeta> {
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>;
}
if (dry) {
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,
},
} satisfies RoleResult<CommitterMeta>;
}
const task = committerPrompt({
nerveRoot,
workflowName,
userPrompt: planner.userPrompt,
testerReason: tester.reason,
});
const committed = await runHermesCommitter(task, nerveRoot);
return {
content: committed.success
? committed.log
: `committer failed: ${committed.error ?? "unknown"}`,
meta: committed,
} satisfies RoleResult<CommitterMeta>;
};
}

View File

@ -0,0 +1,35 @@
export type CommitterPromptParams = {
nerveRoot: string;
workflowName: string;
userPrompt: string;
testerReason: string;
};
export function committerPrompt({
nerveRoot,
workflowName,
userPrompt,
testerReason,
}: CommitterPromptParams): string {
return `You are a git committer subagent for Nerve workflow generation.
Repository root: ${nerveRoot}
Goal:
- Commit and push generated workflow "${workflowName}".
- Handle dirty worktree safely (do not discard unrelated user edits).
- Detect default branch automatically.
- Create a focused branch for this workflow update.
- Stage only workflow files and required config updates.
Context:
- User prompt summary: ${userPrompt.slice(0, 500)}
- Tester result: ${testerReason}
Expected output format:
BRANCH=<branch-or-empty>
COMMIT=<hash-or-empty>
PUSHED=<true|false|unknown>
LOG_START
<details>
LOG_END`;
}

View File

@ -0,0 +1,142 @@
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 { plannerPrompt } from "./prompt.js";
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({
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(""),
),
});
export type PlannerMeta = z.infer<typeof plannerMetaSchema>;
export type BuildPlannerDeps = {
provider: LlmProvider;
nerveRoot: string;
workflowsDir: 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),
});
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

@ -0,0 +1,49 @@
import type { LlmMessage } from "@uncaged/nerve-workflow-utils";
export type PlannerPromptParams = {
nerveAgentContext: string;
userPrompt: string;
nerveRoot: string;
workflowsDir: string;
senseGeneratorReference: string;
nerveYaml: string;
};
export function plannerPrompt({
nerveAgentContext,
userPrompt,
nerveRoot,
workflowsDir,
senseGeneratorReference,
nerveYaml,
}: PlannerPromptParams): LlmMessage[] {
const content = `Design a Nerve workflow plan from this request.
${nerveAgentContext}
User request:
${userPrompt}
Target root: ${nerveRoot}
Workflow dir root: ${workflowsDir}
Reference structure:
\`\`\`ts
${senseGeneratorReference.slice(0, 18_000)}
\`\`\`
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 }];
}

View File

@ -0,0 +1,153 @@
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 { testerPrompt } from "./prompt.js";
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(""),
});
export type TesterMeta = z.infer<typeof testerMetaSchema>;
export type BuildTesterDeps = {
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 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

@ -0,0 +1,34 @@
export type TesterPromptParams = {
workflowName: string;
plannerSpec: object;
coderOutput: string;
nerveRoot: string;
};
export function testerPrompt({
workflowName,
plannerSpec,
coderOutput,
nerveRoot: _nerveRoot,
}: TesterPromptParams): string {
return `You are testing a generated Nerve workflow by doing a dry-run review.
Workflow: ${workflowName}
Planner specification:
${JSON.stringify(plannerSpec, null, 2)}
Coder output summary:
${coderOutput.slice(0, 6000)}
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>
or
FAIL|<reason>|<compact markdown log>`;
}