diff --git a/workflows/workflow-generator/index.ts b/workflows/workflow-generator/index.ts index 5e7e97c..a515423 100644 --- a/workflows/workflow-generator/index.ts +++ b/workflows/workflow-generator/index.ts @@ -1,13 +1,7 @@ -import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; -import type { - RoleResult, - StartStep, - WorkflowDefinition, - WorkflowMessage, -} from "@uncaged/nerve-core"; -import { END, parseNerveConfig } from "@uncaged/nerve-core"; +import type { RoleResult, StartStep, WorkflowDefinition, WorkflowMessage } from "@uncaged/nerve-core"; +import { END } from "@uncaged/nerve-core"; import type { SpawnError } from "@uncaged/nerve-workflow-utils"; import { cursorAgent, @@ -23,11 +17,96 @@ const HOME = process.env.HOME ?? "/home/azureuser"; const NERVE_ROOT = join(HOME, ".uncaged-nerve"); const WORKFLOWS_DIR = join(NERVE_ROOT, "workflows"); +type PlannerRole = { + name: string; + goal: string; + 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.string().default(""), + validationLoopsDesign: z.string().default(""), + externalDeps: z.string().default(""), + dataFlow: z.string().default(""), + planMarkdown: 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 { const result = await spawnSafe("cfg", ["get", key], { cwd: NERVE_ROOT, @@ -37,7 +116,8 @@ async function cfgGet(key: string): Promise { if (!result.ok) { return null; } - return result.value.stdout.trim() || null; + const v = result.value.stdout.trim(); + return v.length > 0 ? v : null; } async function resolveDashScopeProvider(): Promise<{ @@ -54,31 +134,6 @@ async function resolveDashScopeProvider(): Promise<{ return { apiKey, baseUrl, model }; } -function formatSpawnFailure(error: SpawnError): string { - if (error.kind === "spawn_failed") { - return error.message; - } - if (error.kind === "timeout") { - return `timeout (stdout=${error.stdout.slice(0, 200)})`; - } - return `exit ${error.exitCode} stderr=${error.stderr.slice(0, 400)}`; -} - -function spawnErrorStreams(error: SpawnError): { stdout: string; stderr: string } { - if (error.kind === "spawn_failed") { - return { stdout: "", stderr: "" }; - } - return { stdout: error.stdout, stderr: error.stderr }; -} - -function buildSenseGeneratorReference(): string { - const ref = join(WORKFLOWS_DIR, "sense-generator", "index.ts"); - if (!existsSync(ref)) { - return "(reference file workflows/sense-generator/index.ts not found)"; - } - return readFileSync(ref, "utf-8"); -} - function lastMetaForRole(messages: WorkflowMessage[], role: string): M | null { for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].role === role) { @@ -88,584 +143,307 @@ function lastMetaForRole(messages: WorkflowMessage[], role: string): M | null return null; } -const roleEntrySchema = z - .object({ - name: z.string().default("").describe("Role key / identifier in kebab-case or short snake name"), - description: z.string().default("").describe("What this role does in one or two sentences"), - responsibilities: z - .string() - .default("") - .describe("Concrete responsibilities, inputs, and outputs for this role"), - }) - .describe("One role in the generated workflow"); - -const analystExtractSchema = z - .object({ - workflowName: z - .string() - .default("") - .describe("kebab-case package directory name under workflows/, e.g. 'ticket-triage'"), - roles: z.array(roleEntrySchema).default([]).describe("Planned roles for the new workflow"), - moderatorFlow: z - .string() - .default("") - .describe("How the moderator should route between roles; start and exit conditions"), - externalDeps: z - .string() - .default("") - .describe("External tools, CLIs, HTTP APIs, or services the workflow must integrate with"), - dataFlow: z - .string() - .default("") - .describe("How data moves between roles: what each step consumes and produces in content/meta"), - }) - .describe("Structured workflow specification extracted from the analysis"); - -type AnalystMetaItem = { - name: string; - description: string; - responsibilities: string; -}; - -type WorkflowMeta = { - analyst: { - userPrompt: string; - analysis: string; - workflowName: string; - roles: AnalystMetaItem[]; - moderatorFlow: string; - externalDeps: string; - dataFlow: string; - }; - architect: { workflowName: string; design: string }; - coder: { - workflowName: string; - files: { indexTs: boolean; packageJson: boolean; tsconfigJson: boolean }; - cursorOutput: string; - }; - reviewer: { - passed: boolean; - workflowName: string; - reason: string; - attempt: number; - validationLog: string; - }; - committer: { - branch: string | null; - commitHash: string | null; - pushed: boolean | null; - skipped: boolean; - error: string | null; - stagedPaths: string[]; - }; -}; - -const emptyAnalystMeta = (userContent: string): WorkflowMeta["analyst"] => ({ - userPrompt: userContent, - analysis: "", - workflowName: "", - roles: [], - moderatorFlow: "", - externalDeps: "", - dataFlow: "", -}); - -function verifyNerveWorkflowEntry(workflowName: string): { ok: true } | { ok: false; reason: string } { - const readResult = readNerveYaml({ nerveRoot: NERVE_ROOT }); - if (!readResult.ok) { - return { ok: false, reason: `readNerveYaml: ${readResult.error.message}` }; - } - const parsed = parseNerveConfig(readResult.value); - if (!parsed.ok) { - return { ok: false, reason: `parseNerveConfig: ${parsed.error.message}` }; - } - if (parsed.value.workflows[workflowName] === undefined) { - return { ok: false, reason: `nerve.yaml has no workflows.${workflowName} entry` }; - } - return { ok: true }; -} - function scanGeneratedCodePitfalls(source: string): string[] { const issues: string[] = []; if (/\bawait\s+import\s*\(/.test(source)) { - issues.push( - "Uses the await keyword with a parenthesized import() call — only allowed in sense-runtime / workflow-worker with a documented comment", - ); + issues.push("Found await import() in generated workflow code"); } if (/\bimport\s*\(\s*["'`]/.test(source) && !source.includes("Dynamic import required")) { - issues.push("Dynamic import() without documented exception comment"); + issues.push("Found undocumented dynamic import() call"); } - if (/\bexport\s+default\s+/.test(source) === false) { - issues.push("Missing default export of WorkflowDefinition (engine loads the default export)"); + if (!/\bexport\s+default\s+/.test(source)) { + issues.push("Missing default export of WorkflowDefinition"); } return issues; } -async function runReviewerValidation( - workflowDir: string, - workflowName: string, - dry: boolean, -): Promise<{ ok: true; log: string } | { ok: false; log: string; reason: string }> { - const logParts: string[] = []; - - const indexPath = join(workflowDir, "index.ts"); - const pkgPath = join(workflowDir, "package.json"); - const tsconfigPath = join(workflowDir, "tsconfig.json"); - if (!existsSync(indexPath) || !existsSync(pkgPath) || !existsSync(tsconfigPath)) { - const miss: string[] = []; - if (!existsSync(indexPath)) miss.push("index.ts"); - if (!existsSync(pkgPath)) miss.push("package.json"); - if (!existsSync(tsconfigPath)) miss.push("tsconfig.json"); - return { ok: false, log: "", reason: `Missing required file(s): ${miss.join(", ")}` }; - } - - const source = readFileSync(indexPath, "utf-8"); - const pitfalls = scanGeneratedCodePitfalls(source); - if (pitfalls.length > 0) { - const pitfallText = pitfalls.join("\n"); - logParts.push(`=== static checks ===\n${pitfallText}`); - return { ok: false, log: logParts.join("\n\n"), reason: pitfallText }; - } - - const tsc = await spawnSafe("npx", ["tsc", "--noEmit"], { - cwd: workflowDir, - env: null, - timeoutMs: 300_000, - dryRun: dry, - }); - if (!tsc.ok) { - const msg = formatSpawnFailure(tsc.error); - logParts.push(`=== npx tsc --noEmit ===\n${msg}`); - return { ok: false, log: logParts.join("\n\n"), reason: `Typecheck failed: ${msg}` }; - } - const tscOut = tsc.value.stderr.trim() || tsc.value.stdout.trim() || "(no output)"; - logParts.push(`=== npx tsc --noEmit ===\n${tscOut}`); - - const nerveCheck = verifyNerveWorkflowEntry(workflowName); - if (!nerveCheck.ok) { - logParts.push(`=== nerve.yaml ===\n${nerveCheck.reason}`); - return { - ok: false, - log: logParts.join("\n\n"), - reason: `nerve.yaml: ${nerveCheck.reason}`, - }; - } - logParts.push(`=== nerve.yaml ===\nworkflows.${workflowName} is present.`); - - const importLines = source.split("\n").filter((l) => /^\s*import\s/.test(l)); - logParts.push(`=== import lines ===\n${importLines.join("\n")}`); - - return { ok: true, log: logParts.join("\n\n") }; -} - -function summarizeText(s: string, maxLen: number): string { - const one = s.replace(/\s+/g, " ").trim(); - if (one.length <= maxLen) { - return one; - } - return `${one.slice(0, maxLen - 3)}...`; -} - -function sanitizeBranchSegment(name: string): string { - const t = name - .trim() - .replace(/[^a-zA-Z0-9_-]+/g, "-") - .replace(/^-+|-+$/g, ""); - return t.length > 0 ? t : "workflow"; -} - -function resolveWorkflowNameForCommitter(messages: WorkflowMessage[]): string { - const rev = lastMetaForRole(messages, "reviewer"); - if (rev !== null && rev.workflowName.trim().length > 0) { - return rev.workflowName.trim(); +function inferWorkflowName(messages: WorkflowMessage[]): string { + const tester = lastMetaForRole(messages, "tester"); + if (tester !== null && tester.workflowName.trim().length > 0) { + return tester.workflowName.trim(); } const coder = lastMetaForRole(messages, "coder"); if (coder !== null && coder.workflowName.trim().length > 0) { return coder.workflowName.trim(); } - const analyst = lastMetaForRole(messages, "analyst"); - if (analyst !== null && analyst.workflowName.trim().length > 0) { - return analyst.workflowName.trim(); + const planner = lastMetaForRole(messages, "planner"); + if (planner !== null && planner.workflowName.trim().length > 0) { + return planner.workflowName.trim(); } return ""; } -function buildCoreStagePaths( +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, - files: WorkflowMeta["coder"]["files"], - includeNerveYaml: boolean, -): string[] { - const base = `workflows/${workflowName}`; - const paths: string[] = []; - if (files.indexTs) { - paths.push(`${base}/index.ts`); + 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", + }; } - if (files.packageJson) { - paths.push(`${base}/package.json`); - const lockRel = `${base}/pnpm-lock.yaml`; - if (existsSync(join(NERVE_ROOT, lockRel))) { - paths.push(lockRel); - } + 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|| +or +FAIL||`; + + 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: "", + }; } - if (files.tsconfigJson) { - paths.push(`${base}/tsconfig.json`); + 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 }; } - if (includeNerveYaml) { - paths.push("nerve.yaml"); - } - return paths; + const parts = text.split("|"); + const reason = parts[1] ?? "no reason"; + const log = parts.slice(2).join("|").trim(); + return { passed: pass, reason, log }; } -async function nerveYamlShouldBeStaged(workflowName: string, start: StartStep): Promise { - const dry = isDryRun(start); - if (dry) { - return verifyNerveWorkflowEntry(workflowName).ok; - } - const st = await spawnSafe("git", ["status", "--porcelain", "--", "nerve.yaml"], { - cwd: NERVE_ROOT, - env: null, - timeoutMs: 30_000, - dryRun: false, - }); - if (st.ok && st.value.stdout.trim().length > 0) { - return true; - } - const d1 = await spawnSafe("git", ["diff", "--name-only", "--", "nerve.yaml"], { - cwd: NERVE_ROOT, - env: null, - timeoutMs: 30_000, - dryRun: false, - }); - if (d1.ok && d1.value.stdout.trim().length > 0) { - return true; - } - const d2 = await spawnSafe("git", ["diff", "--cached", "--name-only", "--", "nerve.yaml"], { - cwd: NERVE_ROOT, - env: null, - timeoutMs: 30_000, - dryRun: false, - }); - return d2.ok && d2.value.stdout.trim().length > 0; -} +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} -async function listUntrackedUnderWorkflowDir(workflowName: string, start: StartStep): Promise { - const dry = isDryRun(start); - if (dry) { - return []; - } - const prefix = `workflows/${workflowName}/`; - const r = await spawnSafe("git", ["status", "--porcelain", "-u", "--", prefix], { - cwd: NERVE_ROOT, - env: null, - timeoutMs: 30_000, - dryRun: false, - }); - if (!r.ok) { - return []; - } - const out: string[] = []; - for (const line of r.value.stdout.split("\n")) { - const t = line.trimEnd(); - if (t.startsWith("?? ")) { - const p = t.slice(3).trim(); - if (p.startsWith(prefix)) { - out.push(p); - } - } - } - return out; -} +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= +COMMIT= +PUSHED= +LOG_START +
+LOG_END`; -async function resolveDefaultBranchName(start: StartStep, logLines: string[]): Promise { - const dry = isDryRun(start); - const sym = await spawnSafe("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], { - cwd: NERVE_ROOT, - env: null, - timeoutMs: 10_000, - dryRun: dry, - }); - if (sym.ok) { - const out = sym.value.stdout.trim(); - if (out.length > 0 && !out.includes("[dryRun]")) { - const m = out.match(/refs\/remotes\/origin\/(.+)$/); - if (m !== null && m[1] !== undefined && m[1].length > 0) { - logLines.push(`[branch] default via symbolic-ref: ${m[1]}`); - return m[1]; - } - } - } if (dry) { - logLines.push("[branch] dry-run: assuming default branch name `main`"); - return "main"; + return { + invoked: true, + success: true, + branch: "wf/dry-run", + commitHash: null, + pushed: null, + log: "[dry-run] skipped hermes committer", + error: null, + }; } - const abbrev = await spawnSafe("git", ["rev-parse", "--abbrev-ref", "origin/HEAD"], { - cwd: NERVE_ROOT, - env: null, - timeoutMs: 10_000, - dryRun: dry, - }); - if (abbrev.ok) { - const line = abbrev.value.stdout.trim(); - if (line.length > 0 && line.includes("/")) { - const parts = line.split("/"); - const last = parts[parts.length - 1]; - if (last !== undefined && last.length > 0) { - logLines.push(`[branch] default via origin/HEAD: ${last}`); - return last; - } - } - } - for (const b of ["main", "master"] as const) { - const v = await spawnSafe("git", ["rev-parse", "--verify", `refs/remotes/origin/${b}`], { + + 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: 10_000, - dryRun: dry, + timeoutMs: 600_000, + dryRun: false, }); - if (v.ok) { - logLines.push(`[branch] default fallback: origin/${b} exists`); - return b; + 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, + }; } - logLines.push("[branch] default fallback: main (no origin/* resolved)"); - return "main"; -} -function appendIoSnippet(logLines: string[], label: string, stdout: string, stderr: string): void { - const so = stdout.trim().slice(0, 500); - const se = stderr.trim().slice(0, 500); - if (so.length > 0) { - logLines.push(`${label} stdout (truncated): ${so}`); - } - if (se.length > 0) { - logLines.push(`${label} stderr (truncated): ${se}`); + 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 = { name: "workflow-generator", roles: { - async analyst( + async planner( start: StartStep, _messages: WorkflowMessage[], - ): Promise> { + ): Promise> { const dry = isDryRun(start); - const userInput = start.content; - const empty = emptyAnalystMeta(userInput); - const provider = await resolveDashScopeProvider(); + const userPrompt = start.content; + if (provider === null) { return { - content: - "Cannot run analyst: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL (or configure via `cfg get`), " + - "and optionally DASHSCOPE_MODEL.", - meta: empty, - }; - } - - const askPrompt = `You are analyzing a user request to build a new Nerve **workflow** (multi-role automaton with a moderator). - -${nerveAgentContext} - -User's natural language description: -${userInput} - -Nerve root: ${NERVE_ROOT} -Target workflows live under: ${WORKFLOWS_DIR}// - -## Your task -- Clarify the goal, constraints, and success criteria. -- Identify a good kebab-case workflow package name. -- Propose a role breakdown: what each role should do, in order. -- Describe how a moderator should route between roles and when to end. -- List external tools/APIs and how data should flow in \`content\` vs \`meta\` between roles. - -Current nerve.yaml (for context only; do not edit here): -\`\`\`yaml -${getNerveYaml()} -\`\`\` - -For reference, here is a complete existing workflow (patterns to mirror, not to copy literally): -\`\`\`ts -${buildSenseGeneratorReference().slice(0, 18_000)} -\`\`\` - -Output a thorough analysis in markdown. Do not write final implementation code.`; - - const planResult = await cursorAgent({ - prompt: askPrompt, - mode: "ask", - cwd: NERVE_ROOT, - env: null, - timeoutMs: null, - dryRun: dry, - }); - if (!planResult.ok) { - return { - content: `cursor-agent failed: ${formatSpawnFailure(planResult.error)}`, - meta: { ...empty, analysis: "" }, - }; - } - const analysis = planResult.value; - - const extracted = await llmExtract({ - text: analysis, - schema: analystExtractSchema, - provider, - dryRun: dry, - }); - if (dry) { - return { - content: "[dry-run] analyst complete", + content: "Cannot run planner: missing DASHSCOPE_API_KEY or DASHSCOPE_BASE_URL.", meta: { - ...empty, - analysis: analysis || "(dry-run)", - workflowName: "dry-run-test", - roles: [{ name: "placeholder", description: "dry-run role", responsibilities: "n/a" }], - moderatorFlow: "placeholder → END", - externalDeps: "none", - dataFlow: "n/a", - }, - }; - } - if (!extracted.ok) { - return { - content: `${analysis}\n\n[llmExtract error] ${JSON.stringify(extracted.error)}`, - meta: { - userPrompt: userInput, - analysis, + userPrompt, workflowName: "", roles: [], - moderatorFlow: "", + flowTransitions: "", + validationLoopsDesign: "", externalDeps: "", dataFlow: "", + planMarkdown: "", }, }; } - const e = extracted.value; - const summary = - `## Analysis\n\n${analysis}\n\n` + - `## Structured spec\n\n` + - `**workflowName:** ${e.workflowName}\n\n` + - `**moderatorFlow:**\n${e.moderatorFlow}\n\n` + - `**externalDeps:**\n${e.externalDeps}\n\n` + - `**dataFlow:**\n${e.dataFlow}\n\n` + - `**roles:**\n` + - e.roles - .map( - (r, i) => - `${i + 1}. **${r.name}** — ${r.description}\n - ${r.responsibilities}`, - ) - .join("\n\n"); - - return { - content: summary, - meta: { - userPrompt: userInput, - analysis, - workflowName: e.workflowName, - roles: e.roles, - moderatorFlow: e.moderatorFlow, - externalDeps: e.externalDeps, - dataFlow: e.dataFlow, - }, - }; - }, - - async architect( - start: StartStep, - messages: WorkflowMessage[], - ): Promise> { - const dry = isDryRun(start); - if (dry) { - return { - content: "[dry-run] architect complete", - meta: { workflowName: "dry-run-test", design: "(dry-run design)" }, - }; - } - const spec = lastMetaForRole(messages, "analyst"); - if (spec === null) { - return { - content: "Architect skipped — no analyst output in message history.", - meta: { workflowName: "", design: "" }, - }; - } - const wfName = spec.workflowName.trim(); - - if (wfName.length === 0) { - return { - content: "Architect skipped — analyst did not produce a workflow name.", - meta: { workflowName: "", design: "" }, - }; - } - - const rolesText = spec.roles - .map( - (r) => - `### ${r.name}\n- **description:** ${r.description}\n- **responsibilities:** ${r.responsibilities}`, - ) - .join("\n\n"); - - const designPrompt = `You are the architect for a new Nerve **workflow** (multi-role state machine with a \`WorkflowDefinition\` and moderator). + const planningText = `Design a Nerve workflow plan from this request. ${nerveAgentContext} -Target package directory: ${WORKFLOWS_DIR}/${wfName}/ +User request: +${userPrompt} -## Analyst output +Target root: ${NERVE_ROOT} +Workflow dir root: ${WORKFLOWS_DIR} -**User prompt:** -${spec.userPrompt} - -**Moderator / routing (from analyst):** -${spec.moderatorFlow} - -**External dependencies:** -${spec.externalDeps} - -**Data flow:** -${spec.dataFlow} - -**Roles (planned):** -${rolesText} - -## Your task (design document only, no file contents) - -Produce an implementation-ready design in markdown: - -1. **Meta type (TypeScript)** - - A concrete \`type WorkflowMeta = { ... }\` using \`type\` (not interface), no optional \`?:\` — use \`T | null\` for nullable fields. - - One entry per role with the exact fields each role will put in \`RoleResult\` meta. - -2. **Role functions** - - For each role: parameters (\`StartStep\`, \`WorkflowMessage[]\`), return \`RoleResult<…>\`, what to read from \`start\` / prior messages, what to put in \`content\` vs \`meta\`. - -3. **Moderator** - - Pseudocode for \`moderator(context)\` using \`END\` from \`@uncaged/nerve-core\`, edge conditions, and error paths (routed in moderator, not via process exit). - -4. **Error handling** - - How each role reports recoverable failure (content + meta) and how the moderator steers the thread. - -5. **Imports** - - List required imports from \`@uncaged/nerve-core\` and \`@uncaged/nerve-workflow-utils\` only as needed by the final code. - -6. **Files the coder will write** - - \`${WORKFLOWS_DIR}/${wfName}/index.ts\` — \`export default\` a \`WorkflowDefinition\` - - \`${WORKFLOWS_DIR}/${wfName}/package.json\` with \`"type": "module"\` and dependencies (include \`zod\` if the workflow parses structured data) - - \`${WORKFLOWS_DIR}/${wfName}/tsconfig.json\` — if \`${NERVE_ROOT}/tsconfig.workflow.base.json\` exists, extend it; else a strict NodeNext \`noEmit\` project - -7. **nerve.yaml** - - The coder must add a \`workflows:${wfName}\` block to \`${NERVE_ROOT}/nerve.yaml\` (concurrency, overflow) without removing existing keys. - -8. **Nerve code rules to preserve in the generated \`index.ts\`** - - No dynamic \`import()\` in the generated workflow (except documented exceptions in engine loaders). - - \`type\` over \`interface\`, \`function\` over \`class\` for the workflow’s own code. - -## Reference (meta-workflow style) +Reference structure: \`\`\`ts -${buildSenseGeneratorReference().slice(0, 22_000)} +${buildSenseGeneratorReference().slice(0, 18_000)} \`\`\` Current nerve.yaml: @@ -673,131 +451,139 @@ Current nerve.yaml: ${getNerveYaml()} \`\`\` -Output ONLY the design markdown.`; +Produce a complete markdown plan that includes: +- workflow name +- roles list +- flow/transitions +- validation loops design +- external deps +- data flow`; - const planResult = await cursorAgent({ - prompt: designPrompt, - mode: "ask", - cwd: NERVE_ROOT, - env: null, - timeoutMs: null, + const extracted = await llmExtract({ + text: planningText, + schema: plannerExtractSchema, + provider, dryRun: dry, }); - if (!planResult.ok) { + if (!extracted.ok) { return { - content: `cursor-agent failed: ${formatSpawnFailure(planResult.error)}`, - meta: { workflowName: wfName, design: "" }, + 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: planResult.value, - meta: { workflowName: wfName, design: planResult.value }, + 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> { + async coder(start: StartStep, messages: WorkflowMessage[]): Promise> { const dry = isDryRun(start); - if (dry) { - return { - content: "[dry-run] coder complete", - meta: { - workflowName: "dry-run-test", - files: { indexTs: false, packageJson: false, tsconfigJson: false }, - cursorOutput: "(dry-run)", - }, - }; - } - const analystMeta = lastMetaForRole(messages, "analyst"); - const architectMeta = lastMetaForRole(messages, "architect"); - const priorReviewer = lastMetaForRole(messages, "reviewer"); + const plannerMeta = lastMetaForRole(messages, "planner"); + const previousTester = lastMetaForRole(messages, "tester"); + const attempt = messages.filter((m) => m.role === "coder").length + 1; - if (analystMeta === null || architectMeta === null) { + if (plannerMeta === null || plannerMeta.workflowName.trim().length === 0) { return { - content: "coder: missing analyst or architect message in history", + 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 = analystMeta.workflowName.trim(); - if (wfName.length === 0) { - return { - content: "coder: empty workflow name", - meta: { - workflowName: "", - files: { indexTs: false, packageJson: false, tsconfigJson: false }, - cursorOutput: "", - }, - }; - } - - const fixSection = - priorReviewer !== null && priorReviewer.passed === false - ? `\n\n## Previous review (address these before anything else)\n${priorReviewer.reason}\n\nFull validation log:\n${priorReviewer.validationLog}\n` + 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 codePrompt = `You are implementing a new Nerve workflow package at ${WORKFLOWS_DIR}/${wfName}/. + const codingPrompt = `Implement a Nerve workflow package under ${WORKFLOWS_DIR}/${wfName}/. -## Architect design (authoritative for structure) -${architectMeta.design} +Planner output: +${plannerMeta.planMarkdown} -## Analyst structured fields +Structured planner fields: ${JSON.stringify( { - workflowName: analystMeta.workflowName, - userPrompt: analystMeta.userPrompt, - roles: analystMeta.roles, - moderatorFlow: analystMeta.moderatorFlow, - externalDeps: analystMeta.externalDeps, - dataFlow: analystMeta.dataFlow, + workflowName: plannerMeta.workflowName, + roles: plannerMeta.roles, + flowTransitions: plannerMeta.flowTransitions, + validationLoopsDesign: plannerMeta.validationLoopsDesign, + externalDeps: plannerMeta.externalDeps, + dataFlow: plannerMeta.dataFlow, }, null, 2, )} -${fixSection} +${feedback} -## Files to create or update -1. \`${WORKFLOWS_DIR}/${wfName}/index.ts\` — \`export default\` a \`WorkflowDefinition\` (same style as sense-generator: named imports, default export at end). -2. \`${WORKFLOWS_DIR}/${wfName}/package.json\` — \`"type": "module"\`, dependencies on \`@uncaged/nerve-core\`, \`@uncaged/nerve-workflow-utils\`, \`zod\` if used; add \`typescript\` in devDependencies so \`npx tsc --noEmit\` works in that directory. -3. \`${WORKFLOWS_DIR}/${wfName}/tsconfig.json\` — strict, \`module\`/\`moduleResolution\` NodeNext, \`noEmit: true\`, include all \`.ts\` in the folder. +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} -4. **Register the workflow** — merge a new block into the existing \`${NERVE_ROOT}/nerve.yaml\` under the top-level \`workflows:\` key: - \`\`\`yaml - ${wfName}: - concurrency: 1 - overflow: drop - \`\`\` - Do not remove or overwrite unrelated senses, reflexes, or other workflow entries. Preserve valid YAML. +Rules: +- keep WorkflowDefinition pattern +- no dynamic import() +- use types (not interfaces) +- include retry-aware moderator routing +- write compile-ready TypeScript`; -## CRITICAL constraints -- **ONLY write/modify files inside \`${WORKFLOWS_DIR}/${wfName}/\` and \`${NERVE_ROOT}/nerve.yaml\`.** You may read other workflow directories for reference, but do NOT create, modify, or delete files in them (e.g. \`sense-generator/\`, \`hello-world/\`). - -## Implementation patterns (when applicable) -- \`resolveDashScopeProvider\`, \`nerveAgentContext\`, \`readNerveYaml\`, \`cursorAgent\`, \`llmExtract\`, \`spawnSafe\`, \`formatSpawnFailure\` from \`@uncaged/nerve-workflow-utils\` as in sense-generator. -- No dynamic \`import()\` in the new workflow code. -- **Every zod schema passed to \`llmExtract\` MUST have \`.default()\` on all fields** (especially arrays and objects). This ensures \`dryRun\` mode returns structurally valid data instead of empty primitives. Example: \`z.array(roleSchema).default([])\`, \`z.string().default("unknown")\`. - -## Reference workflow -\`\`\`ts -${buildSenseGeneratorReference().slice(0, 20_000)} -\`\`\` - -Current nerve.yaml (merge carefully; keep all existing content): -\`\`\`yaml -${getNerveYaml()} -\`\`\` - -Implement now.`; - - const agentResult = await cursorAgent({ - prompt: codePrompt, + const agentRun = await cursorAgent({ + prompt: codingPrompt, mode: "default", cwd: NERVE_ROOT, env: null, @@ -811,112 +597,125 @@ Implement now.`; 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 (!agentResult.ok) { - const errText = `cursor-agent failed: ${formatSpawnFailure(agentResult.error)}`; + if (!agentRun.ok) { return { - content: errText, - meta: { workflowName: wfName, files, cursorOutput: errText }, + 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: agentResult.value, - meta: { workflowName: wfName, files, cursorOutput: agentResult.value }, + 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 reviewer( - start: StartStep, - messages: WorkflowMessage[], - ): Promise> { + async tester(start: StartStep, messages: WorkflowMessage[]): Promise> { const dry = isDryRun(start); - if (dry) { - const attempt = messages.filter((m) => m.role === "reviewer").length + 1; - return { - content: "[dry-run] reviewer complete — validation skipped; treating as PASS", - meta: { - passed: true, - workflowName: "dry-run-test", - reason: "Dry-run: reviewer validation not executed", - attempt, - validationLog: "(dry-run)", - }, - }; - } - const coderEntry = lastMetaForRole(messages, "coder"); - const attempt = messages.filter((m) => m.role === "reviewer").length + 1; + const plannerMeta = lastMetaForRole(messages, "planner"); + const coderMeta = lastMetaForRole(messages, "coder"); + const attempt = messages.filter((m) => m.role === "tester").length + 1; - if (coderEntry === null) { + if (plannerMeta === null || coderMeta === null) { return { - content: "FAIL — no coder message in history", + content: "tester cannot continue: missing planner/coder output", meta: { - passed: false, workflowName: "", - reason: "Reviewer could not find a prior coder step", attempt, - validationLog: "", - }, - }; - } - - const { workflowName, files } = coderEntry; - - const missing: string[] = []; - if (!files.indexTs) missing.push("index.ts"); - if (!files.packageJson) missing.push("package.json"); - if (!files.tsconfigJson) missing.push("tsconfig.json"); - if (missing.length > 0) { - return { - content: `FAIL — missing: ${missing.join(", ")}`, - meta: { passed: false, - workflowName, - reason: `Missing required file(s): ${missing.join(", ")}`, - attempt, - validationLog: "", + dryRunLog: "", + reason: "missing planner/coder output", }, }; } - - const name = workflowName.trim(); - if (name.length === 0) { + if (!coderMeta.lintPassed || !coderMeta.buildPassed) { return { - content: "FAIL — empty workflow name in coder meta", + content: "tester blocked: coder has not passed lint+build", meta: { - passed: false, - workflowName: "", - reason: "Coder meta had empty workflowName", + workflowName: coderMeta.workflowName, attempt, - validationLog: "", - }, - }; - } - - const workflowDir = join(WORKFLOWS_DIR, name); - const checks = await runReviewerValidation(workflowDir, name, dry); - - if (!checks.ok) { - return { - content: `FAIL — ${checks.reason}`, - meta: { - passed: false, - workflowName: name, - reason: checks.reason, - attempt, - validationLog: checks.log, + 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: `PASS — typecheck and nerve.yaml check OK.\n\n${checks.log.slice(0, 8000)}`, + content: `${dryRun.passed ? "PASS" : "FAIL"} — ${dryRun.reason}`, meta: { - passed: true, - workflowName: name, - reason: "npx tsc --noEmit passed and nerve.yaml contains the workflow entry", + workflowName: coderMeta.workflowName, attempt, - validationLog: checks.log, + passed: dryRun.passed, + dryRunLog: dryRun.log, + reason: dryRun.reason, }, }; }, @@ -926,383 +725,79 @@ Implement now.`; messages: WorkflowMessage[], ): Promise> { const dry = isDryRun(start); - const logLines: string[] = []; - const gitDir = join(NERVE_ROOT, ".git"); - const nullMeta = (): WorkflowMeta["committer"] => ({ - branch: null, - commitHash: null, - pushed: null, - skipped: true, - error: null, - stagedPaths: [], - }); + const planner = lastMetaForRole(messages, "planner"); + const tester = lastMetaForRole(messages, "tester"); + const workflowName = inferWorkflowName(messages); - logLines.push("[1] Check `.git` at NERVE_ROOT"); - if (!existsSync(gitDir)) { - logLines.push(`Result: no .git at ${NERVE_ROOT} — skipping all git operations.`); + if (planner === null || tester === null || workflowName.length === 0) { return { - content: logLines.join("\n"), - meta: nullMeta(), - }; - } - - const analystMeta = lastMetaForRole(messages, "analyst"); - const userPrompt = analystMeta?.userPrompt ?? ""; - const reviewerMeta = lastMetaForRole(messages, "reviewer"); - const coderMeta = lastMetaForRole(messages, "coder"); - const files = - coderMeta !== null - ? coderMeta.files - : { indexTs: false, packageJson: false, tsconfigJson: false }; - - const wfName = resolveWorkflowNameForCommitter(messages); - if (wfName.length === 0) { - logLines.push("ERROR: could not resolve workflowName from analyst/coder/reviewer meta."); - return { - content: logLines.join("\n"), + content: "committer skipped: missing planner/tester/workflowName context", meta: { + invoked: false, + success: false, branch: null, commitHash: null, pushed: null, - skipped: false, - error: "Empty workflowName — cannot infer paths to stage", - stagedPaths: [], + 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 includeNerve = await nerveYamlShouldBeStaged(wfName, start); - const untracked = await listUntrackedUnderWorkflowDir(wfName, start); - const corePaths = buildCoreStagePaths(wfName, files, includeNerve); - const plannedSet = new Set(corePaths); - for (const u of untracked) { - plannedSet.add(u); - } - const plannedPaths = [...plannedSet].filter( - (p) => p === "nerve.yaml" || existsSync(join(NERVE_ROOT, p)), + const committed = await runHermesCommitter( + workflowName, + planner.userPrompt, + tester.reason, + dry, ); - - const dryPlanPaths = - plannedPaths.length > 0 - ? plannedPaths - : buildCoreStagePaths( - wfName, - { indexTs: true, packageJson: true, tsconfigJson: true }, - verifyNerveWorkflowEntry(wfName).ok, - ); - - if (plannedPaths.length === 0 && !dry) { - logLines.push("No candidate paths to `git add` (all file flags false and nerve.yaml not staged)."); - return { - content: logLines.join("\n"), - meta: { - branch: null, - commitHash: null, - pushed: null, - skipped: false, - error: "Nothing to stage for this workflow", - stagedPaths: [], - }, - }; - } - - const defaultBranch = await resolveDefaultBranchName(start, logLines); - const shortSuffix = Date.now().toString(36); - const newBranch = `wf/${sanitizeBranchSegment(wfName)}-${shortSuffix}`; - - if (dry) { - logLines.push("[dry-run] Would run: `git checkout ` then `git checkout -b " + newBranch + "`"); - logLines.push(`[dry-run] Would run: \`git add -- ${dryPlanPaths.join(" ")}\``); - logLines.push("[dry-run] Would run: `git commit` with message summarizing workflow + user prompt + reviewer"); - logLines.push(`[dry-run] Would run: \`git push -u origin ${newBranch}\``); - return { - content: logLines.join("\n"), - meta: { - branch: newBranch, - commitHash: null, - pushed: null, - skipped: false, - error: null, - stagedPaths: dryPlanPaths, - }, - }; - } - - logLines.push(`[2] git checkout ${defaultBranch}`); - const checkoutBase = await spawnSafe("git", ["checkout", defaultBranch], { - cwd: NERVE_ROOT, - env: null, - timeoutMs: 120_000, - dryRun: false, - }); - if (!checkoutBase.ok) { - const err = formatSpawnFailure(checkoutBase.error); - const io = spawnErrorStreams(checkoutBase.error); - appendIoSnippet(logLines, "checkout", io.stdout, io.stderr); - logLines.push(`ERROR: ${err}`); - return { - content: logLines.join("\n"), - meta: { - branch: null, - commitHash: null, - pushed: null, - skipped: false, - error: `git checkout ${defaultBranch}: ${err}`, - stagedPaths: [], - }, - }; - } - appendIoSnippet(logLines, "checkout", checkoutBase.value.stdout, checkoutBase.value.stderr); - - logLines.push(`[3] git checkout -b ${newBranch}`); - const checkoutNew = await spawnSafe("git", ["checkout", "-b", newBranch], { - cwd: NERVE_ROOT, - env: null, - timeoutMs: 120_000, - dryRun: false, - }); - if (!checkoutNew.ok) { - const err = formatSpawnFailure(checkoutNew.error); - const ioNb = spawnErrorStreams(checkoutNew.error); - appendIoSnippet(logLines, "checkout -b", ioNb.stdout, ioNb.stderr); - logLines.push(`ERROR: ${err}`); - return { - content: logLines.join("\n"), - meta: { - branch: null, - commitHash: null, - pushed: null, - skipped: false, - error: `git checkout -b: ${err}`, - stagedPaths: [], - }, - }; - } - appendIoSnippet(logLines, "checkout -b", checkoutNew.value.stdout, checkoutNew.value.stderr); - - logLines.push(`[4] git add -- ${plannedPaths.join(" ")}`); - const addArgs = ["add", "--", ...plannedPaths]; - const addR = await spawnSafe("git", addArgs, { - cwd: NERVE_ROOT, - env: null, - timeoutMs: 120_000, - dryRun: false, - }); - if (!addR.ok) { - const err = formatSpawnFailure(addR.error); - const ioAdd = spawnErrorStreams(addR.error); - appendIoSnippet(logLines, "git add", ioAdd.stdout, ioAdd.stderr); - logLines.push(`ERROR: ${err}`); - return { - content: logLines.join("\n"), - meta: { - branch: newBranch, - commitHash: null, - pushed: null, - skipped: false, - error: `git add: ${err}`, - stagedPaths: [], - }, - }; - } - appendIoSnippet(logLines, "git add", addR.value.stdout, addR.value.stderr); - - const stagedR = await spawnSafe("git", ["diff", "--cached", "--name-only"], { - cwd: NERVE_ROOT, - env: null, - timeoutMs: 30_000, - dryRun: false, - }); - const stagedPaths = stagedR.ok - ? stagedR.value.stdout - .split("\n") - .map((s) => s.trim()) - .filter((s) => s.length > 0) - : []; - - const userBrief = summarizeText(userPrompt, 200); - const reasonBrief = - reviewerMeta !== null ? summarizeText(reviewerMeta.reason, 240) : "(no reviewer reason)"; - const subject = `workflow: ${wfName}`; - const body = - `Workflow: ${wfName}\n` + - `User request (summary): ${userBrief}\n` + - `Reviewer (summary): ${reasonBrief}\n` + - `Staged paths:\n${stagedPaths.map((p) => `- ${p}`).join("\n") || "(none)"}\n`; - const commitMessage = `${subject}\n\n${body}`; - - const msgPath = join(tmpdir(), `nerve-workflow-generator-commit-${Date.now()}.txt`); - logLines.push(`[5] git commit -F ${msgPath}`); - let commitHash: string | null = null; - let commitErr: string | null = null; - try { - writeFileSync(msgPath, commitMessage, "utf-8"); - const commitR = await spawnSafe("git", ["commit", "-F", msgPath], { - cwd: NERVE_ROOT, - env: null, - timeoutMs: 120_000, - dryRun: false, - }); - if (!commitR.ok) { - commitErr = `git commit: ${formatSpawnFailure(commitR.error)}`; - const ioCommit = spawnErrorStreams(commitR.error); - appendIoSnippet(logLines, "git commit", ioCommit.stdout, ioCommit.stderr); - logLines.push(`ERROR: ${commitErr}`); - } else { - appendIoSnippet(logLines, "git commit", commitR.value.stdout, commitR.value.stderr); - const revR = await spawnSafe("git", ["rev-parse", "HEAD"], { - cwd: NERVE_ROOT, - env: null, - timeoutMs: 10_000, - dryRun: false, - }); - if (!revR.ok) { - commitErr = `git rev-parse HEAD: ${formatSpawnFailure(revR.error)}`; - logLines.push(`ERROR: ${commitErr}`); - } else { - commitHash = revR.value.stdout.trim() || null; - logLines.push(`[6] commit hash: ${commitHash ?? "(empty)"}`); - } - } - } finally { - try { - unlinkSync(msgPath); - } catch { - /* ignore */ - } - } - - if (commitErr !== null) { - return { - content: logLines.filter(Boolean).join("\n"), - meta: { - branch: newBranch, - commitHash, - pushed: null, - skipped: false, - error: commitErr, - stagedPaths, - }, - }; - } - - logLines.push("[7] git remote get-url origin"); - const urlR = await spawnSafe("git", ["remote", "get-url", "origin"], { - cwd: NERVE_ROOT, - env: null, - timeoutMs: 10_000, - dryRun: false, - }); - if (!urlR.ok) { - logLines.push(`No origin remote (skip push): ${formatSpawnFailure(urlR.error)}`); - return { - content: logLines.filter(Boolean).join("\n"), - meta: { - branch: newBranch, - commitHash, - pushed: null, - skipped: false, - error: null, - stagedPaths, - }, - }; - } - const originUrl = urlR.value.stdout.trim(); - if (originUrl.length === 0) { - logLines.push("origin URL empty — skip push"); - return { - content: logLines.filter(Boolean).join("\n"), - meta: { - branch: newBranch, - commitHash, - pushed: null, - skipped: false, - error: null, - stagedPaths, - }, - }; - } - logLines.push(`origin: ${originUrl}`); - - logLines.push(`[8] git push -u origin ${newBranch}`); - const pushR = await spawnSafe("git", ["push", "-u", "origin", newBranch], { - cwd: NERVE_ROOT, - env: null, - timeoutMs: 300_000, - dryRun: false, - }); - if (!pushR.ok) { - const pe = `git push: ${formatSpawnFailure(pushR.error)}`; - const ioPush = spawnErrorStreams(pushR.error); - appendIoSnippet(logLines, "git push", ioPush.stdout, ioPush.stderr); - logLines.push(`ERROR: ${pe}`); - return { - content: logLines.filter(Boolean).join("\n"), - meta: { - branch: newBranch, - commitHash, - pushed: false, - skipped: false, - error: pe, - stagedPaths, - }, - }; - } - appendIoSnippet(logLines, "git push", pushR.value.stdout, pushR.value.stderr); - return { - content: logLines.filter(Boolean).join("\n"), - meta: { - branch: newBranch, - commitHash, - pushed: true, - skipped: false, - error: null, - stagedPaths, - }, + content: committed.success ? committed.log : `committer failed: ${committed.error ?? "unknown"}`, + meta: committed, }; }, }, moderator(context) { if (context.steps.length === 0) { - return "analyst"; + return "planner"; } - const last = context.steps[context.steps.length - 1]; - if (last.role === "analyst") { - if (last.meta.workflowName.trim().length === 0) { - return END; - } - return "architect"; + if (last.role === "planner") { + return last.meta.workflowName.trim().length > 0 ? "coder" : END; } - - if (last.role === "architect") { - if (last.meta.workflowName.trim().length === 0 || last.meta.design.trim().length === 0) { - return END; - } - return "coder"; - } - if (last.role === "coder") { - return "reviewer"; - } - - if (last.role === "reviewer") { - if (last.meta.passed === true) { - return "committer"; + if (last.meta.lintPassed && last.meta.buildPassed) { + return "tester"; } - if (last.meta.passed === false && last.meta.attempt < 3) { + if (last.meta.attempt < 3) { return "coder"; } return END; } - - if (last.role === "committer") { + if (last.role === "tester") { + if (last.meta.passed) { + return "committer"; + } + if (last.meta.attempt < 3) { + return "coder"; + } return END; } - return END; }, };