From a469f30b420cac1b9dd663e7c19ebe3c53ddd609 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Tue, 28 Apr 2026 08:48:23 +0000 Subject: [PATCH] refactor(workflow-generator): multi-file DIP + Role Factory + esbuild bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- workflows/workflow-generator/build.ts | 31 + workflows/workflow-generator/dist/index.js | 846 ++++++++++++++++++ workflows/workflow-generator/index.ts | 807 +---------------- workflows/workflow-generator/moderator.ts | 45 + workflows/workflow-generator/package.json | 4 + workflows/workflow-generator/pnpm-lock.yaml | 271 ++++++ .../workflow-generator/roles/coder/index.ts | 254 ++++++ .../workflow-generator/roles/coder/prompt.ts | 39 + .../roles/committer/index.ts | 190 ++++ .../roles/committer/prompt.ts | 35 + .../workflow-generator/roles/planner/index.ts | 142 +++ .../roles/planner/prompt.ts | 49 + .../workflow-generator/roles/tester/index.ts | 153 ++++ .../workflow-generator/roles/tester/prompt.ts | 34 + 14 files changed, 2103 insertions(+), 797 deletions(-) create mode 100644 workflows/workflow-generator/build.ts create mode 100644 workflows/workflow-generator/dist/index.js create mode 100644 workflows/workflow-generator/moderator.ts create mode 100644 workflows/workflow-generator/roles/coder/index.ts create mode 100644 workflows/workflow-generator/roles/coder/prompt.ts create mode 100644 workflows/workflow-generator/roles/committer/index.ts create mode 100644 workflows/workflow-generator/roles/committer/prompt.ts create mode 100644 workflows/workflow-generator/roles/planner/index.ts create mode 100644 workflows/workflow-generator/roles/planner/prompt.ts create mode 100644 workflows/workflow-generator/roles/tester/index.ts create mode 100644 workflows/workflow-generator/roles/tester/prompt.ts diff --git a/workflows/workflow-generator/build.ts b/workflows/workflow-generator/build.ts new file mode 100644 index 0000000..8565532 --- /dev/null +++ b/workflows/workflow-generator/build.ts @@ -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 { + 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, + }; +} diff --git a/workflows/workflow-generator/dist/index.js b/workflows/workflow-generator/dist/index.js new file mode 100644 index 0000000..0154309 --- /dev/null +++ b/workflows/workflow-generator/dist/index.js @@ -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 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|| +or +FAIL||`; +} + +// 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= +COMMIT= +PUSHED= +LOG_START +
+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 +}; diff --git a/workflows/workflow-generator/index.ts b/workflows/workflow-generator/index.ts index 5d1dd2f..61afeb3 100644 --- a/workflows/workflow-generator/index.ts +++ b/workflows/workflow-generator/index.ts @@ -1,807 +1,20 @@ -import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; -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, - isDryRun, - llmExtract, - nerveAgentContext, - readNerveYaml, - spawnSafe, -} from "@uncaged/nerve-workflow-utils"; -import { z } from "zod"; +import { buildWorkflowGenerator } from "./build.js"; 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; -}; +const apiKey = process.env.DASHSCOPE_API_KEY; +const baseUrl = process.env.DASHSCOPE_BASE_URL; +const model = process.env.DASHSCOPE_MODEL ?? "qwen-plus"; -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; - }; -}; +if (!apiKey || !baseUrl) { + throw new Error("Set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL"); +} -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("")), +const workflow = buildWorkflowGenerator({ + provider: { apiKey, baseUrl, model }, + nerveRoot: NERVE_ROOT, }); -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, - 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) { - return null; - } - return { apiKey, baseUrl, model }; -} - -function lastMetaForRole(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 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(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 planner = lastMetaForRole(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|| -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: "", - }; - } - 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= -COMMIT= -PUSHED= -LOG_START -
-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 = { - name: "workflow-generator", - - roles: { - async planner( - start: StartStep, - _messages: WorkflowMessage[], - ): Promise> { - 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> { - const dry = isDryRun(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 - ? `\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 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> { - const dry = isDryRun(start); - const plannerMeta = lastMetaForRole(messages, "planner"); - const coderMeta = lastMetaForRole(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> { - const dry = isDryRun(start); - const planner = lastMetaForRole(messages, "planner"); - const tester = lastMetaForRole(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; diff --git a/workflows/workflow-generator/moderator.ts b/workflows/workflow-generator/moderator.ts new file mode 100644 index 0000000..3a0bac5 --- /dev/null +++ b/workflows/workflow-generator/moderator.ts @@ -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 = (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; +}; diff --git a/workflows/workflow-generator/package.json b/workflows/workflow-generator/package.json index 69a378b..63d73b5 100644 --- a/workflows/workflow-generator/package.json +++ b/workflows/workflow-generator/package.json @@ -3,6 +3,9 @@ "version": "0.0.1", "private": true, "type": "module", + "scripts": { + "build": "esbuild index.ts --bundle --platform=node --format=esm --outdir=dist --packages=external" + }, "dependencies": { "@uncaged/nerve-core": "latest", "@uncaged/nerve-workflow-utils": "latest", @@ -10,6 +13,7 @@ }, "devDependencies": { "@types/node": "^22.0.0", + "esbuild": "^0.27.0", "typescript": "^5.7.0" }, "pnpm": { diff --git a/workflows/workflow-generator/pnpm-lock.yaml b/workflows/workflow-generator/pnpm-lock.yaml index 237146e..2f15cb9 100644 --- a/workflows/workflow-generator/pnpm-lock.yaml +++ b/workflows/workflow-generator/pnpm-lock.yaml @@ -26,15 +26,179 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.19.17 + esbuild: + specifier: ^0.27.0 + version: 0.27.7 typescript: specifier: ^5.7.0 version: 5.9.3 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': 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: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -48,10 +212,117 @@ packages: 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': dependencies: 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: {} undici-types@6.21.0: {} diff --git a/workflows/workflow-generator/roles/coder/index.ts b/workflows/workflow-generator/roles/coder/index.ts new file mode 100644 index 0000000..cce2258 --- /dev/null +++ b/workflows/workflow-generator/roles/coder/index.ts @@ -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; + +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(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 { + return async (start, messages) => { + const dry = isDryRun(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", + }, + } satisfies RoleResult; + } + + 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; + } + + 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; + } + + 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; + } + + 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; + }; +} diff --git a/workflows/workflow-generator/roles/coder/prompt.ts b/workflows/workflow-generator/roles/coder/prompt.ts new file mode 100644 index 0000000..a6c249c --- /dev/null +++ b/workflows/workflow-generator/roles/coder/prompt.ts @@ -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 pattern +- no dynamic import() +- use types (not interfaces) +- include retry-aware moderator routing +- write compile-ready TypeScript`; +} diff --git a/workflows/workflow-generator/roles/committer/index.ts b/workflows/workflow-generator/roles/committer/index.ts new file mode 100644 index 0000000..cffdac8 --- /dev/null +++ b/workflows/workflow-generator/roles/committer/index.ts @@ -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; + +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(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(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(messages, "planner"); + if (planner !== null && planner.workflowName.trim().length > 0) { + return planner.workflowName.trim(); + } + return ""; +} + +async function runHermesCommitter( + task: string, + nerveRoot: string, +): Promise { + 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 { + return async (start, messages) => { + const dry = isDryRun(start); + const planner = lastMetaForRole(messages, "planner"); + const tester = lastMetaForRole(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; + } + + if (!tester.passed) { + return { + content: "committer skipped: tester not passed", + meta: { ...skipMeta, error: "tester not passed" }, + } satisfies RoleResult; + } + + 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; + } + + 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; + }; +} diff --git a/workflows/workflow-generator/roles/committer/prompt.ts b/workflows/workflow-generator/roles/committer/prompt.ts new file mode 100644 index 0000000..fad7b8b --- /dev/null +++ b/workflows/workflow-generator/roles/committer/prompt.ts @@ -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= +COMMIT= +PUSHED= +LOG_START +
+LOG_END`; +} diff --git a/workflows/workflow-generator/roles/planner/index.ts b/workflows/workflow-generator/roles/planner/index.ts new file mode 100644 index 0000000..61f78d4 --- /dev/null +++ b/workflows/workflow-generator/roles/planner/index.ts @@ -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; + +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 { + 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; + } + + 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; + }; +} diff --git a/workflows/workflow-generator/roles/planner/prompt.ts b/workflows/workflow-generator/roles/planner/prompt.ts new file mode 100644 index 0000000..e3cc8d4 --- /dev/null +++ b/workflows/workflow-generator/roles/planner/prompt.ts @@ -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 }]; +} diff --git a/workflows/workflow-generator/roles/tester/index.ts b/workflows/workflow-generator/roles/tester/index.ts new file mode 100644 index 0000000..d3a4388 --- /dev/null +++ b/workflows/workflow-generator/roles/tester/index.ts @@ -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; + +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(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 { + return async (start, messages) => { + const dry = isDryRun(start); + const plannerMeta = lastMetaForRole(messages, "planner"); + const coderMeta = lastMetaForRole(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; + } + + 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; + } + + 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; + } + + 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; + } + + 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; + } + + 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; + }; +} diff --git a/workflows/workflow-generator/roles/tester/prompt.ts b/workflows/workflow-generator/roles/tester/prompt.ts new file mode 100644 index 0000000..187fdf4 --- /dev/null +++ b/workflows/workflow-generator/roles/tester/prompt.ts @@ -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|| +or +FAIL||`; +}