From d6386234560c2f1ecdcee3f9dafef6e8b935819c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Tue, 28 Apr 2026 10:22:57 +0000 Subject: [PATCH] refactor(workflow-generator): simplify meta to routing booleans + log-to-file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - planner: { ready }, coder: { done }, tester: { passed }, committer: { success } - planner/coder: createCursorRole, tester: createHermesRole - committer: direct spawn, output to .log file - moderator: coder loop (max 5), committer fail → coder - bundle 24kb → 8.7kb Fixes #5 --- workflows/workflow-generator/build.ts | 7 +- workflows/workflow-generator/moderator.ts | 38 ++- .../workflow-generator/roles/coder/index.ts | 255 +----------------- .../workflow-generator/roles/coder/prompt.ts | 64 +++-- .../roles/committer/index.ts | 227 +++++----------- .../workflow-generator/roles/planner/index.ts | 141 +--------- .../roles/planner/prompt.ts | 66 ++--- .../workflow-generator/roles/tester/index.ts | 153 +---------- .../workflow-generator/roles/tester/prompt.ts | 47 ++-- 9 files changed, 188 insertions(+), 810 deletions(-) diff --git a/workflows/workflow-generator/build.ts b/workflows/workflow-generator/build.ts index 8565532..ee9cf36 100644 --- a/workflows/workflow-generator/build.ts +++ b/workflows/workflow-generator/build.ts @@ -17,13 +17,12 @@ 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 }), + planner: buildPlannerRole({ provider, cwd: nerveRoot }), + coder: buildCoderRole({ provider, cwd: nerveRoot }), + tester: buildTesterRole({ provider }), committer: buildCommitterRole({ nerveRoot }), }, moderator, diff --git a/workflows/workflow-generator/moderator.ts b/workflows/workflow-generator/moderator.ts index 3a0bac5..2f437a4 100644 --- a/workflows/workflow-generator/moderator.ts +++ b/workflows/workflow-generator/moderator.ts @@ -12,34 +12,32 @@ export type WorkflowMeta = { committer: CommitterMeta; }; +const MAX_CODER_ITERATIONS = 5; + export const moderator: Moderator = (context) => { - if (context.steps.length === 0) { - return "planner"; - } + if (context.steps.length === 0) return "planner"; + const last = context.steps[context.steps.length - 1]; + const coderCount = context.steps.filter((s) => s.role === "coder").length; if (last.role === "planner") { - if (last.meta.workflowName.trim().length > 0) return "coder"; - const plannerAttempts = context.steps.filter((s) => s.role === "planner").length; - return plannerAttempts < 3 ? "planner" : END; + return last.meta.ready ? "coder" : END; } + if (last.role === "coder") { - if (last.meta.lintPassed && last.meta.buildPassed) { - return "tester"; - } - if (last.meta.attempt < 3) { - return "coder"; - } - return END; + if (last.meta.done) return "tester"; + return coderCount < MAX_CODER_ITERATIONS ? "coder" : END; } + if (last.role === "tester") { - if (last.meta.passed) { - return "committer"; - } - if (last.meta.attempt < 3) { - return "coder"; - } - return END; + if (last.meta.passed) return "committer"; + return coderCount < MAX_CODER_ITERATIONS ? "coder" : END; } + + if (last.role === "committer") { + if (last.meta.success) return END; + return coderCount < MAX_CODER_ITERATIONS ? "coder" : END; + } + return END; }; diff --git a/workflows/workflow-generator/roles/coder/index.ts b/workflows/workflow-generator/roles/coder/index.ts index cce2258..4deac31 100644 --- a/workflows/workflow-generator/roles/coder/index.ts +++ b/workflows/workflow-generator/roles/coder/index.ts @@ -1,254 +1,23 @@ -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import type { Role, RoleResult, WorkflowMessage } from "@uncaged/nerve-core"; -import type { SpawnError } from "@uncaged/nerve-workflow-utils"; -import { cursorAgent, isDryRun, spawnSafe } from "@uncaged/nerve-workflow-utils"; -import { z } from "zod"; -import type { PlannerMeta } from "../planner/index.js"; -import type { TesterMeta } from "../tester/index.js"; +import type { LlmProvider } from "@uncaged/nerve-workflow-utils"; +import { createCursorRole } from "@uncaged/nerve-workflow-utils"; import { coderPrompt } from "./prompt.js"; +import { z } from "zod"; export const coderMetaSchema = z.object({ - workflowName: z.string().default(""), - attempt: z.number().default(1), - files: z - .object({ - indexTs: z.boolean().default(false), - packageJson: z.boolean().default(false), - tsconfigJson: z.boolean().default(false), - }) - .default({ indexTs: false, packageJson: false, tsconfigJson: false }), - lintPassed: z.boolean().default(false), - buildPassed: z.boolean().default(false), - lintLog: z.string().default(""), - buildLog: z.string().default(""), - cursorOutput: z.string().default(""), - reason: z.string().nullable().default(null), + done: z.boolean().describe("true if the workflow files were created and build passes"), }); - export type CoderMeta = z.infer; export type BuildCoderDeps = { - nerveRoot: string; - workflowsDir: string; + provider: LlmProvider; + cwd: string; }; -function formatSpawnFailure(error: SpawnError): string { - if (error.kind === "spawn_failed") { - return error.message; - } - if (error.kind === "timeout") { - return `timeout stdout=${error.stdout.slice(0, 300)} stderr=${error.stderr.slice(0, 300)}`; - } - return `exit ${error.exitCode} stderr=${error.stderr.slice(0, 500)}`; -} - -function scanGeneratedCodePitfalls(source: string): string[] { - const issues: string[] = []; - if (/\bawait\s+import\s*\(/.test(source)) { - issues.push("Found await import() in generated workflow code"); - } - if (/\bimport\s*\(\s*["'`]/.test(source) && !source.includes("Dynamic import required")) { - issues.push("Found undocumented dynamic import() call"); - } - if (!/\bexport\s+default\s+/.test(source)) { - issues.push("Missing default export of WorkflowDefinition"); - } - return issues; -} - -async function runLintAndBuild( - workflowDir: string, - dry: boolean, -): Promise<{ - lintPassed: boolean; - buildPassed: boolean; - lintLog: string; - buildLog: string; - reason: string | null; -}> { - const lintRun = await spawnSafe("pnpm", ["run", "check"], { - cwd: workflowDir, - env: null, - timeoutMs: 300_000, - dryRun: dry, +export function buildCoderRole({ provider, cwd }: BuildCoderDeps) { + return createCursorRole({ + cwd, + mode: "default", + prompt: async (threadId) => coderPrompt({ threadId }), + extract: { provider, schema: coderMetaSchema }, }); - 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 index a6c249c..8cba488 100644 --- a/workflows/workflow-generator/roles/coder/prompt.ts +++ b/workflows/workflow-generator/roles/coder/prompt.ts @@ -1,39 +1,35 @@ -export type CoderPromptParams = { - workflowsDir: string; - wfName: string; - planMarkdown: string; - plannerStructured: object; - feedback: string; - nerveRoot: string; -}; +export function coderPrompt({ threadId }: { threadId: string }): string { + return `Read the workflow thread to get the planner's design and any tester feedback: \`nerve thread ${threadId}\` +Read the nerve-dev skill for workflow file structure and conventions: \`cat node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\` +Also look at existing workflows in the \`workflows/\` directory for patterns. -export function coderPrompt({ - workflowsDir, - wfName, - planMarkdown, - plannerStructured, - feedback, - nerveRoot, -}: CoderPromptParams): string { - return `Implement a Nerve workflow package under ${workflowsDir}/${wfName}/. +Implement the workflow the planner designed. If there is tester feedback in the thread, fix the issues it identified. -Planner output: -${planMarkdown} - -Structured planner fields: -${JSON.stringify(plannerStructured, null, 2)} -${feedback} - -Required files: -1) ${workflowsDir}/${wfName}/index.ts -2) ${workflowsDir}/${wfName}/package.json -3) ${workflowsDir}/${wfName}/tsconfig.json -4) update ${nerveRoot}/nerve.yaml with workflows.${wfName} +Required files for each workflow: +- \`workflows//index.ts\` — WorkflowDefinition default export +- \`workflows//package.json\` — with esbuild build script +- \`workflows//tsconfig.json\` — TypeScript config +- Update \`nerve.yaml\` with \`workflows.\` Rules: -- keep WorkflowDefinition pattern -- no dynamic import() -- use types (not interfaces) -- include retry-aware moderator routing -- write compile-ready TypeScript`; +- Keep the WorkflowDefinition pattern +- No dynamic import() +- Use types (not interfaces) +- Include retry-aware moderator routing +- Write compile-ready TypeScript + +After creating all files, run inside the workflow directory: +\`\`\` +pnpm install --no-cache && pnpm build +\`\`\` + +End your response with a JSON block: +\`\`\`json +{ "done": true } +\`\`\` +if build succeeded, or: +\`\`\`json +{ "done": false } +\`\`\` +if there were errors.`; } diff --git a/workflows/workflow-generator/roles/committer/index.ts b/workflows/workflow-generator/roles/committer/index.ts index cffdac8..2e6624b 100644 --- a/workflows/workflow-generator/roles/committer/index.ts +++ b/workflows/workflow-generator/roles/committer/index.ts @@ -1,190 +1,83 @@ -import type { Role, RoleResult, WorkflowMessage } from "@uncaged/nerve-core"; -import type { SpawnError } from "@uncaged/nerve-workflow-utils"; -import { cursorAgent, isDryRun, spawnSafe } from "@uncaged/nerve-workflow-utils"; -import { z } from "zod"; -import type { PlannerMeta } from "../planner/index.js"; -import type { TesterMeta } from "../tester/index.js"; -import { committerPrompt } from "./prompt.js"; +import { writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import type { Role, RoleResult } from "@uncaged/nerve-core"; +import { isDryRun, spawnSafe } from "@uncaged/nerve-workflow-utils"; -export const committerMetaSchema = z.object({ - invoked: z.boolean().default(false), - success: z.boolean().default(false), - branch: z.string().nullable().default(null), - commitHash: z.string().nullable().default(null), - pushed: z.boolean().nullable().default(null), - log: z.string().default(""), - error: z.string().nullable().default(null), -}); - -export type CommitterMeta = z.infer; +export type CommitterMeta = { + success: boolean; +}; export type BuildCommitterDeps = { nerveRoot: string; }; -function formatSpawnFailure(error: SpawnError): string { - if (error.kind === "spawn_failed") { - return error.message; - } - if (error.kind === "timeout") { - return `timeout stdout=${error.stdout.slice(0, 300)} stderr=${error.stderr.slice(0, 300)}`; - } - return `exit ${error.exitCode} stderr=${error.stderr.slice(0, 500)}`; +function logPath(nerveRoot: string): string { + return join(nerveRoot, "logs", `committer-${Date.now()}.log`); } -function lastMetaForRole(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, - }; +function writeLog(path: string, content: string): void { + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, content, "utf-8"); } export function buildCommitterRole({ nerveRoot }: BuildCommitterDeps): Role { - return async (start, messages) => { + 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; - } + const file = logPath(nerveRoot); if (dry) { + writeLog(file, "[dry-run] committer skipped\n"); return { - content: "[dry-run] skipped hermes committer", - meta: { - invoked: true, - success: true, - branch: "wf/dry-run", - commitHash: null, - pushed: null, - log: "[dry-run] skipped hermes committer", - error: null, - }, + content: `[dry-run] committer skipped — log: ${file}`, + meta: { success: true }, } satisfies RoleResult; } - const task = committerPrompt({ - nerveRoot, - workflowName, - userPrompt: planner.userPrompt, - testerReason: tester.reason, - }); + const lines: string[] = []; + let success = true; - const committed = await runHermesCommitter(task, nerveRoot); + const run = async (cmd: string, args: string[]): Promise => { + const r = await spawnSafe(cmd, args, { cwd: nerveRoot, env: null, timeoutMs: 60_000, dryRun: false }); + if (r.ok) { + lines.push(`$ ${cmd} ${args.join(" ")}`); + if (r.value.stdout) lines.push(r.value.stdout); + if (r.value.stderr) lines.push(r.value.stderr); + lines.push(""); + return true; + } + const e = r.error; + lines.push(`$ ${cmd} ${args.join(" ")} — FAILED`); + if (e.kind === "non_zero_exit") { + lines.push(`exit ${e.exitCode}`); + if (e.stdout) lines.push(e.stdout); + if (e.stderr) lines.push(e.stderr); + } else if (e.kind === "timeout") { + lines.push("timeout"); + if (e.stdout) lines.push(e.stdout); + if (e.stderr) lines.push(e.stderr); + } else { + lines.push(e.message); + } + lines.push(""); + return false; + }; + + await run("git", ["add", "-A"]); + const committed = await run("git", ["commit", "-m", "chore: add generated workflow"]); + if (!committed) { + success = false; + } else { + const pushed = await run("git", ["push"]); + if (!pushed) success = false; + } + + const log = lines.join("\n"); + writeLog(file, log); + + const summary = success ? "committed and pushed" : "commit/push failed — see log"; return { - content: committed.success - ? committed.log - : `committer failed: ${committed.error ?? "unknown"}`, - meta: committed, + content: `committer: ${summary}\nLog: ${file}`, + meta: { success }, } satisfies RoleResult; }; } diff --git a/workflows/workflow-generator/roles/planner/index.ts b/workflows/workflow-generator/roles/planner/index.ts index 61f78d4..339c6ef 100644 --- a/workflows/workflow-generator/roles/planner/index.ts +++ b/workflows/workflow-generator/roles/planner/index.ts @@ -1,142 +1,23 @@ -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import type { Role, RoleResult } from "@uncaged/nerve-core"; import type { LlmProvider } from "@uncaged/nerve-workflow-utils"; -import { isDryRun, llmExtract, nerveAgentContext, readNerveYaml } from "@uncaged/nerve-workflow-utils"; -import { z } from "zod"; +import { createCursorRole } from "@uncaged/nerve-workflow-utils"; import { plannerPrompt } from "./prompt.js"; - -const roleSchema = z - .object({ - name: z.string().default(""), - goal: z.string().default(""), - io: z.string().default(""), - }) - .default({ name: "", goal: "", io: "" }); +import { z } from "zod"; export const plannerMetaSchema = z.object({ - userPrompt: z.string().default(""), - workflowName: z - .string() - .default("") - .describe("kebab-case workflow name under workflows/, e.g. issue-fixer"), - roles: z.array(roleSchema).default([]), - flowTransitions: z.preprocess((v) => (Array.isArray(v) ? v.join("\n") : v), z.string().default("")), - validationLoopsDesign: z.preprocess( - (v) => (Array.isArray(v) ? v.join("\n") : v), - z.string().default(""), - ), - externalDeps: z.preprocess( - (v) => (Array.isArray(v) ? v.join(", ") : v), - z.string().default(""), - ), - dataFlow: z.preprocess((v) => (Array.isArray(v) ? v.join("\n") : v), z.string().default("")), - planMarkdown: z.preprocess( - (v) => (Array.isArray(v) ? v.join("\n") : v), - z.string().default(""), - ), + ready: z.boolean().describe("true if requirements are clear and a workflow can be implemented"), }); - export type PlannerMeta = z.infer; export type BuildPlannerDeps = { provider: LlmProvider; - nerveRoot: string; - workflowsDir: string; + cwd: string; }; -function getNerveYaml(nerveRoot: string): string { - const result = readNerveYaml({ nerveRoot }); - return result.ok ? result.value : "# nerve.yaml unavailable"; -} - -function getSenseGeneratorReference(workflowsDir: string): string { - const p = join(workflowsDir, "sense-generator", "index.ts"); - if (!existsSync(p)) { - return "(missing workflows/sense-generator/index.ts)"; - } - return readFileSync(p, "utf-8"); -} - -export function buildPlannerRole({ - provider, - nerveRoot, - workflowsDir, -}: BuildPlannerDeps): Role { - 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; - }; +export function buildPlannerRole({ provider, cwd }: BuildPlannerDeps) { + return createCursorRole({ + cwd, + mode: "ask", + prompt: async (threadId) => plannerPrompt({ threadId }), + extract: { provider, schema: plannerMetaSchema }, + }); } diff --git a/workflows/workflow-generator/roles/planner/prompt.ts b/workflows/workflow-generator/roles/planner/prompt.ts index e3cc8d4..528a3a6 100644 --- a/workflows/workflow-generator/roles/planner/prompt.ts +++ b/workflows/workflow-generator/roles/planner/prompt.ts @@ -1,49 +1,31 @@ -import type { LlmMessage } from "@uncaged/nerve-workflow-utils"; +export function plannerPrompt({ threadId }: { threadId: string }): string { + return `You are planning a new Nerve workflow. -export type PlannerPromptParams = { - nerveAgentContext: string; - userPrompt: string; - nerveRoot: string; - workflowsDir: string; - senseGeneratorReference: string; - nerveYaml: string; -}; +Read the workflow thread for the user's request: \`nerve thread ${threadId}\` +Read the nerve-dev skill for workflow conventions: \`cat node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\` +Also look at existing workflows in the \`workflows/\` directory for patterns. -export function plannerPrompt({ - nerveAgentContext, - userPrompt, - nerveRoot, - workflowsDir, - senseGeneratorReference, - nerveYaml, -}: PlannerPromptParams): LlmMessage[] { - const content = `Design a Nerve workflow plan from this request. +Assess whether the requirements are clear enough to implement a workflow. -${nerveAgentContext} +If requirements ARE clear: +- Pick a good kebab-case name for this workflow. +- Produce a PLAN (not code) in markdown covering: + - Workflow name + - Roles list (name, purpose, tool) + - Flow transitions / moderator routing logic + - Validation loops design + - External dependencies + - Data flow between roles -User request: -${userPrompt} +If requirements are NOT clear: +- Describe what is missing or ambiguous. -Target root: ${nerveRoot} -Workflow dir root: ${workflowsDir} - -Reference structure: -\`\`\`ts -${senseGeneratorReference.slice(0, 18_000)} +End your response with a JSON block: +\`\`\`json +{ "ready": true } \`\`\` - -Current nerve.yaml: -\`\`\`yaml -${nerveYaml} -\`\`\` - -Produce a complete markdown plan that includes: -- workflow name -- roles list -- flow/transitions -- validation loops design -- external deps -- data flow`; - - return [{ role: "user", content }]; +or +\`\`\`json +{ "ready": false } +\`\`\``; } diff --git a/workflows/workflow-generator/roles/tester/index.ts b/workflows/workflow-generator/roles/tester/index.ts index d3a4388..38b5741 100644 --- a/workflows/workflow-generator/roles/tester/index.ts +++ b/workflows/workflow-generator/roles/tester/index.ts @@ -1,153 +1,20 @@ -import type { Role, RoleResult, WorkflowMessage } from "@uncaged/nerve-core"; -import type { SpawnError } from "@uncaged/nerve-workflow-utils"; -import { cursorAgent, isDryRun } from "@uncaged/nerve-workflow-utils"; -import { z } from "zod"; -import type { CoderMeta } from "../coder/index.js"; -import type { PlannerMeta } from "../planner/index.js"; +import type { LlmProvider } from "@uncaged/nerve-workflow-utils"; +import { createHermesRole } from "@uncaged/nerve-workflow-utils"; import { testerPrompt } from "./prompt.js"; +import { z } from "zod"; export const testerMetaSchema = z.object({ - workflowName: z.string().default(""), - attempt: z.number().default(1), - passed: z.boolean().default(false), - dryRunLog: z.string().default(""), - reason: z.string().default(""), + passed: z.boolean().describe("true if all validation checks passed"), }); - export type TesterMeta = z.infer; export type BuildTesterDeps = { - nerveRoot: string; + provider: LlmProvider; }; -function formatSpawnFailure(error: SpawnError): string { - if (error.kind === "spawn_failed") { - return error.message; - } - if (error.kind === "timeout") { - return `timeout stdout=${error.stdout.slice(0, 300)} stderr=${error.stderr.slice(0, 300)}`; - } - return `exit ${error.exitCode} stderr=${error.stderr.slice(0, 500)}`; -} - -function lastMetaForRole(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; - }; +export function buildTesterRole({ provider }: BuildTesterDeps) { + return createHermesRole({ + prompt: async (threadId) => testerPrompt({ threadId }), + extract: { provider, schema: testerMetaSchema }, + }); } diff --git a/workflows/workflow-generator/roles/tester/prompt.ts b/workflows/workflow-generator/roles/tester/prompt.ts index 187fdf4..684396f 100644 --- a/workflows/workflow-generator/roles/tester/prompt.ts +++ b/workflows/workflow-generator/roles/tester/prompt.ts @@ -1,34 +1,27 @@ -export type TesterPromptParams = { - workflowName: string; - plannerSpec: object; - coderOutput: string; - nerveRoot: string; -}; +export function testerPrompt({ threadId }: { threadId: string }): string { + return `You are testing a newly generated Nerve workflow end-to-end. -export function testerPrompt({ - workflowName, - plannerSpec, - coderOutput, - nerveRoot: _nerveRoot, -}: TesterPromptParams): string { - return `You are testing a generated Nerve workflow by doing a dry-run review. +Read the workflow thread for context: \`nerve thread ${threadId}\` +Read the nerve-dev skill for expected file structure: \`cat node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\` -Workflow: ${workflowName} +Get the workflow name from the thread (the planner's output). -Planner specification: -${JSON.stringify(plannerSpec, null, 2)} +Verify the full lifecycle: +1. Check all required workflow files exist: index.ts, package.json, tsconfig.json +2. Check nerve.yaml has the workflow config +3. Run \`nerve workflow list\` — confirm the workflow appears +4. Run \`pnpm build\` inside the workflow directory — must succeed +5. Run \`nerve workflow dry-run \` — should complete without error +6. If any step fails, include the relevant error output -Coder output summary: -${coderOutput.slice(0, 6000)} +Output a clear summary: what you checked, what passed, what failed, and why. -Required checks: -1) Verify role transitions are coherent and terminates to END. -2) Verify generated workflow adheres to planner intent. -3) Verify retry loops are explicit for recoverable failures. -4) Verify no obvious runtime-breaking issue in generated index.ts. - -Return exactly: -PASS|| +End your response with a JSON block: +\`\`\`json +{ "passed": true } +\`\`\` or -FAIL||`; +\`\`\`json +{ "passed": false } +\`\`\``; }