refactor(workflow-generator): simplify meta to routing booleans + log-to-file
- planner: { ready }, coder: { done }, tester: { passed }, committer: { success }
- planner/coder: createCursorRole, tester: createHermesRole
- committer: direct spawn, output to .log file
- moderator: coder loop (max 5), committer fail → coder
- bundle 24kb → 8.7kb
Fixes #5
This commit is contained in:
parent
bf77e3452a
commit
d638623456
@ -17,13 +17,12 @@ export function buildWorkflowGenerator({
|
||||
provider,
|
||||
nerveRoot,
|
||||
}: BuildWorkflowGeneratorDeps): WorkflowDefinition<WorkflowMeta> {
|
||||
const workflowsDir = join(nerveRoot, "workflows");
|
||||
return {
|
||||
name: "workflow-generator",
|
||||
roles: {
|
||||
planner: buildPlannerRole({ provider, nerveRoot, workflowsDir }),
|
||||
coder: buildCoderRole({ nerveRoot, workflowsDir }),
|
||||
tester: buildTesterRole({ nerveRoot }),
|
||||
planner: buildPlannerRole({ provider, cwd: nerveRoot }),
|
||||
coder: buildCoderRole({ provider, cwd: nerveRoot }),
|
||||
tester: buildTesterRole({ provider }),
|
||||
committer: buildCommitterRole({ nerveRoot }),
|
||||
},
|
||||
moderator,
|
||||
|
||||
@ -12,34 +12,32 @@ export type WorkflowMeta = {
|
||||
committer: CommitterMeta;
|
||||
};
|
||||
|
||||
const MAX_CODER_ITERATIONS = 5;
|
||||
|
||||
export const moderator: Moderator<WorkflowMeta> = (context) => {
|
||||
if (context.steps.length === 0) {
|
||||
return "planner";
|
||||
}
|
||||
if (context.steps.length === 0) return "planner";
|
||||
|
||||
const last = context.steps[context.steps.length - 1];
|
||||
const coderCount = context.steps.filter((s) => s.role === "coder").length;
|
||||
|
||||
if (last.role === "planner") {
|
||||
if (last.meta.workflowName.trim().length > 0) return "coder";
|
||||
const plannerAttempts = context.steps.filter((s) => s.role === "planner").length;
|
||||
return plannerAttempts < 3 ? "planner" : END;
|
||||
return last.meta.ready ? "coder" : END;
|
||||
}
|
||||
|
||||
if (last.role === "coder") {
|
||||
if (last.meta.lintPassed && last.meta.buildPassed) {
|
||||
return "tester";
|
||||
}
|
||||
if (last.meta.attempt < 3) {
|
||||
return "coder";
|
||||
}
|
||||
return END;
|
||||
if (last.meta.done) return "tester";
|
||||
return coderCount < MAX_CODER_ITERATIONS ? "coder" : END;
|
||||
}
|
||||
|
||||
if (last.role === "tester") {
|
||||
if (last.meta.passed) {
|
||||
return "committer";
|
||||
}
|
||||
if (last.meta.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;
|
||||
};
|
||||
|
||||
@ -1,254 +1,23 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Role, RoleResult, WorkflowMessage } from "@uncaged/nerve-core";
|
||||
import type { SpawnError } from "@uncaged/nerve-workflow-utils";
|
||||
import { cursorAgent, isDryRun, spawnSafe } from "@uncaged/nerve-workflow-utils";
|
||||
import { z } from "zod";
|
||||
import type { PlannerMeta } from "../planner/index.js";
|
||||
import type { TesterMeta } from "../tester/index.js";
|
||||
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
|
||||
import { createCursorRole } from "@uncaged/nerve-workflow-utils";
|
||||
import { coderPrompt } from "./prompt.js";
|
||||
import { z } from "zod";
|
||||
|
||||
export const coderMetaSchema = z.object({
|
||||
workflowName: z.string().default(""),
|
||||
attempt: z.number().default(1),
|
||||
files: z
|
||||
.object({
|
||||
indexTs: z.boolean().default(false),
|
||||
packageJson: z.boolean().default(false),
|
||||
tsconfigJson: z.boolean().default(false),
|
||||
})
|
||||
.default({ indexTs: false, packageJson: false, tsconfigJson: false }),
|
||||
lintPassed: z.boolean().default(false),
|
||||
buildPassed: z.boolean().default(false),
|
||||
lintLog: z.string().default(""),
|
||||
buildLog: z.string().default(""),
|
||||
cursorOutput: z.string().default(""),
|
||||
reason: z.string().nullable().default(null),
|
||||
done: z.boolean().describe("true if the workflow files were created and build passes"),
|
||||
});
|
||||
|
||||
export type CoderMeta = z.infer<typeof coderMetaSchema>;
|
||||
|
||||
export type BuildCoderDeps = {
|
||||
nerveRoot: string;
|
||||
workflowsDir: string;
|
||||
provider: LlmProvider;
|
||||
cwd: string;
|
||||
};
|
||||
|
||||
function formatSpawnFailure(error: SpawnError): string {
|
||||
if (error.kind === "spawn_failed") {
|
||||
return error.message;
|
||||
}
|
||||
if (error.kind === "timeout") {
|
||||
return `timeout stdout=${error.stdout.slice(0, 300)} stderr=${error.stderr.slice(0, 300)}`;
|
||||
}
|
||||
return `exit ${error.exitCode} stderr=${error.stderr.slice(0, 500)}`;
|
||||
}
|
||||
|
||||
function scanGeneratedCodePitfalls(source: string): string[] {
|
||||
const issues: string[] = [];
|
||||
if (/\bawait\s+import\s*\(/.test(source)) {
|
||||
issues.push("Found await import() in generated workflow code");
|
||||
}
|
||||
if (/\bimport\s*\(\s*["'`]/.test(source) && !source.includes("Dynamic import required")) {
|
||||
issues.push("Found undocumented dynamic import() call");
|
||||
}
|
||||
if (!/\bexport\s+default\s+/.test(source)) {
|
||||
issues.push("Missing default export of WorkflowDefinition");
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
async function runLintAndBuild(
|
||||
workflowDir: string,
|
||||
dry: boolean,
|
||||
): Promise<{
|
||||
lintPassed: boolean;
|
||||
buildPassed: boolean;
|
||||
lintLog: string;
|
||||
buildLog: string;
|
||||
reason: string | null;
|
||||
}> {
|
||||
const lintRun = await spawnSafe("pnpm", ["run", "check"], {
|
||||
cwd: workflowDir,
|
||||
env: null,
|
||||
timeoutMs: 300_000,
|
||||
dryRun: dry,
|
||||
export function buildCoderRole({ provider, cwd }: BuildCoderDeps) {
|
||||
return createCursorRole<CoderMeta>({
|
||||
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<M>(messages: WorkflowMessage[], role: string): M | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === role) {
|
||||
return messages[i].meta as M;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildCoderRole({ nerveRoot, workflowsDir }: BuildCoderDeps): Role<CoderMeta> {
|
||||
return async (start, messages) => {
|
||||
const dry = isDryRun(start);
|
||||
const plannerMeta = lastMetaForRole<PlannerMeta>(messages, "planner");
|
||||
const previousTester = lastMetaForRole<TesterMeta>(messages, "tester");
|
||||
const attempt = messages.filter((m) => m.role === "coder").length + 1;
|
||||
|
||||
if (plannerMeta === null || plannerMeta.workflowName.trim().length === 0) {
|
||||
return {
|
||||
content: "coder cannot continue: missing planner output",
|
||||
meta: {
|
||||
workflowName: "",
|
||||
attempt,
|
||||
files: { indexTs: false, packageJson: false, tsconfigJson: false },
|
||||
lintPassed: false,
|
||||
buildPassed: false,
|
||||
lintLog: "",
|
||||
buildLog: "",
|
||||
cursorOutput: "",
|
||||
reason: "missing planner output",
|
||||
},
|
||||
} satisfies RoleResult<CoderMeta>;
|
||||
}
|
||||
|
||||
const wfName = plannerMeta.workflowName.trim();
|
||||
const feedback =
|
||||
previousTester !== null && previousTester.passed === false
|
||||
? `\n\nPrevious tester failure to fix:\n${previousTester.reason}\n${previousTester.dryRunLog}\n`
|
||||
: "";
|
||||
|
||||
const prompt = coderPrompt({
|
||||
workflowsDir,
|
||||
wfName,
|
||||
planMarkdown: plannerMeta.planMarkdown,
|
||||
plannerStructured: {
|
||||
workflowName: plannerMeta.workflowName,
|
||||
roles: plannerMeta.roles,
|
||||
flowTransitions: plannerMeta.flowTransitions,
|
||||
validationLoopsDesign: plannerMeta.validationLoopsDesign,
|
||||
externalDeps: plannerMeta.externalDeps,
|
||||
dataFlow: plannerMeta.dataFlow,
|
||||
},
|
||||
feedback,
|
||||
nerveRoot,
|
||||
});
|
||||
|
||||
const agentRun = await cursorAgent({
|
||||
prompt,
|
||||
mode: "default",
|
||||
cwd: nerveRoot,
|
||||
env: null,
|
||||
timeoutMs: null,
|
||||
dryRun: dry,
|
||||
});
|
||||
|
||||
const workflowDir = join(workflowsDir, wfName);
|
||||
const files = {
|
||||
indexTs: existsSync(join(workflowDir, "index.ts")),
|
||||
packageJson: existsSync(join(workflowDir, "package.json")),
|
||||
tsconfigJson: existsSync(join(workflowDir, "tsconfig.json")),
|
||||
};
|
||||
const missing = [
|
||||
files.indexTs ? null : "index.ts",
|
||||
files.packageJson ? null : "package.json",
|
||||
files.tsconfigJson ? null : "tsconfig.json",
|
||||
].filter((x) => x !== null) as string[];
|
||||
|
||||
if (!agentRun.ok) {
|
||||
return {
|
||||
content: `coder failed: ${formatSpawnFailure(agentRun.error)}`,
|
||||
meta: {
|
||||
workflowName: wfName,
|
||||
attempt,
|
||||
files,
|
||||
lintPassed: false,
|
||||
buildPassed: false,
|
||||
lintLog: "",
|
||||
buildLog: "",
|
||||
cursorOutput: "",
|
||||
reason: formatSpawnFailure(agentRun.error),
|
||||
},
|
||||
} satisfies RoleResult<CoderMeta>;
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
content: `coder failed: missing required files (${missing.join(", ")})`,
|
||||
meta: {
|
||||
workflowName: wfName,
|
||||
attempt,
|
||||
files,
|
||||
lintPassed: false,
|
||||
buildPassed: false,
|
||||
lintLog: "",
|
||||
buildLog: "",
|
||||
cursorOutput: agentRun.value,
|
||||
reason: `missing files: ${missing.join(", ")}`,
|
||||
},
|
||||
} satisfies RoleResult<CoderMeta>;
|
||||
}
|
||||
|
||||
const source = readFileSync(join(workflowDir, "index.ts"), "utf-8");
|
||||
const pitfalls = scanGeneratedCodePitfalls(source);
|
||||
if (pitfalls.length > 0) {
|
||||
return {
|
||||
content: `coder static check failed:\n${pitfalls.join("\n")}`,
|
||||
meta: {
|
||||
workflowName: wfName,
|
||||
attempt,
|
||||
files,
|
||||
lintPassed: false,
|
||||
buildPassed: false,
|
||||
lintLog: pitfalls.join("\n"),
|
||||
buildLog: "",
|
||||
cursorOutput: agentRun.value,
|
||||
reason: pitfalls.join("; "),
|
||||
},
|
||||
} satisfies RoleResult<CoderMeta>;
|
||||
}
|
||||
|
||||
const check = await runLintAndBuild(workflowDir, dry);
|
||||
const passed = check.lintPassed && check.buildPassed;
|
||||
return {
|
||||
content: passed
|
||||
? `coder PASS: lint+build ok\n\n${check.lintLog}\n\n${check.buildLog}`
|
||||
: `coder FAIL: ${check.reason ?? "unknown error"}`,
|
||||
meta: {
|
||||
workflowName: wfName,
|
||||
attempt,
|
||||
files,
|
||||
lintPassed: check.lintPassed,
|
||||
buildPassed: check.buildPassed,
|
||||
lintLog: check.lintLog,
|
||||
buildLog: check.buildLog,
|
||||
cursorOutput: agentRun.value,
|
||||
reason: check.reason,
|
||||
},
|
||||
} satisfies RoleResult<CoderMeta>;
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,39 +1,35 @@
|
||||
export type CoderPromptParams = {
|
||||
workflowsDir: string;
|
||||
wfName: string;
|
||||
planMarkdown: string;
|
||||
plannerStructured: object;
|
||||
feedback: string;
|
||||
nerveRoot: string;
|
||||
};
|
||||
export function coderPrompt({ threadId }: { threadId: string }): string {
|
||||
return `Read the workflow thread to get the planner's design and any tester feedback: \`nerve thread ${threadId}\`
|
||||
Read the nerve-dev skill for workflow file structure and conventions: \`cat node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\`
|
||||
Also look at existing workflows in the \`workflows/\` directory for patterns.
|
||||
|
||||
export function coderPrompt({
|
||||
workflowsDir,
|
||||
wfName,
|
||||
planMarkdown,
|
||||
plannerStructured,
|
||||
feedback,
|
||||
nerveRoot,
|
||||
}: CoderPromptParams): string {
|
||||
return `Implement a Nerve workflow package under ${workflowsDir}/${wfName}/.
|
||||
Implement the workflow the planner designed. If there is tester feedback in the thread, fix the issues it identified.
|
||||
|
||||
Planner output:
|
||||
${planMarkdown}
|
||||
|
||||
Structured planner fields:
|
||||
${JSON.stringify(plannerStructured, null, 2)}
|
||||
${feedback}
|
||||
|
||||
Required files:
|
||||
1) ${workflowsDir}/${wfName}/index.ts
|
||||
2) ${workflowsDir}/${wfName}/package.json
|
||||
3) ${workflowsDir}/${wfName}/tsconfig.json
|
||||
4) update ${nerveRoot}/nerve.yaml with workflows.${wfName}
|
||||
Required files for each workflow:
|
||||
- \`workflows/<name>/index.ts\` — WorkflowDefinition default export
|
||||
- \`workflows/<name>/package.json\` — with esbuild build script
|
||||
- \`workflows/<name>/tsconfig.json\` — TypeScript config
|
||||
- Update \`nerve.yaml\` with \`workflows.<name>\`
|
||||
|
||||
Rules:
|
||||
- keep WorkflowDefinition<WorkflowMeta> pattern
|
||||
- no dynamic import()
|
||||
- use types (not interfaces)
|
||||
- include retry-aware moderator routing
|
||||
- write compile-ready TypeScript`;
|
||||
- Keep the WorkflowDefinition<WorkflowMeta> pattern
|
||||
- No dynamic import()
|
||||
- Use types (not interfaces)
|
||||
- Include retry-aware moderator routing
|
||||
- Write compile-ready TypeScript
|
||||
|
||||
After creating all files, run inside the workflow directory:
|
||||
\`\`\`
|
||||
pnpm install --no-cache && pnpm build
|
||||
\`\`\`
|
||||
|
||||
End your response with a JSON block:
|
||||
\`\`\`json
|
||||
{ "done": true }
|
||||
\`\`\`
|
||||
if build succeeded, or:
|
||||
\`\`\`json
|
||||
{ "done": false }
|
||||
\`\`\`
|
||||
if there were errors.`;
|
||||
}
|
||||
|
||||
@ -1,190 +1,83 @@
|
||||
import type { Role, RoleResult, WorkflowMessage } from "@uncaged/nerve-core";
|
||||
import type { SpawnError } from "@uncaged/nerve-workflow-utils";
|
||||
import { cursorAgent, isDryRun, spawnSafe } from "@uncaged/nerve-workflow-utils";
|
||||
import { z } from "zod";
|
||||
import type { PlannerMeta } from "../planner/index.js";
|
||||
import type { TesterMeta } from "../tester/index.js";
|
||||
import { committerPrompt } from "./prompt.js";
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Role, RoleResult } from "@uncaged/nerve-core";
|
||||
import { isDryRun, spawnSafe } from "@uncaged/nerve-workflow-utils";
|
||||
|
||||
export const committerMetaSchema = z.object({
|
||||
invoked: z.boolean().default(false),
|
||||
success: z.boolean().default(false),
|
||||
branch: z.string().nullable().default(null),
|
||||
commitHash: z.string().nullable().default(null),
|
||||
pushed: z.boolean().nullable().default(null),
|
||||
log: z.string().default(""),
|
||||
error: z.string().nullable().default(null),
|
||||
});
|
||||
|
||||
export type CommitterMeta = z.infer<typeof committerMetaSchema>;
|
||||
export type CommitterMeta = {
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
export type BuildCommitterDeps = {
|
||||
nerveRoot: string;
|
||||
};
|
||||
|
||||
function formatSpawnFailure(error: SpawnError): string {
|
||||
if (error.kind === "spawn_failed") {
|
||||
return error.message;
|
||||
}
|
||||
if (error.kind === "timeout") {
|
||||
return `timeout stdout=${error.stdout.slice(0, 300)} stderr=${error.stderr.slice(0, 300)}`;
|
||||
}
|
||||
return `exit ${error.exitCode} stderr=${error.stderr.slice(0, 500)}`;
|
||||
function logPath(nerveRoot: string): string {
|
||||
return join(nerveRoot, "logs", `committer-${Date.now()}.log`);
|
||||
}
|
||||
|
||||
function lastMetaForRole<M>(messages: WorkflowMessage[], role: string): M | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === role) {
|
||||
return messages[i].meta as M;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferWorkflowName(messages: WorkflowMessage[]): string {
|
||||
const tester = lastMetaForRole<TesterMeta>(messages, "tester");
|
||||
if (tester !== null && tester.workflowName.trim().length > 0) {
|
||||
return tester.workflowName.trim();
|
||||
}
|
||||
const coder = lastMetaForRole<{ workflowName: string }>(messages, "coder");
|
||||
if (coder !== null && coder.workflowName.trim().length > 0) {
|
||||
return coder.workflowName.trim();
|
||||
}
|
||||
const planner = lastMetaForRole<PlannerMeta>(messages, "planner");
|
||||
if (planner !== null && planner.workflowName.trim().length > 0) {
|
||||
return planner.workflowName.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function runHermesCommitter(
|
||||
task: string,
|
||||
nerveRoot: string,
|
||||
): Promise<CommitterMeta> {
|
||||
const commandAttempts: Array<{ cmd: string; args: string[] }> = [
|
||||
{ cmd: "hermes-agent", args: ["--cwd", nerveRoot, "--task", task] },
|
||||
{ cmd: "hermes", args: ["agent", "--cwd", nerveRoot, "--task", task] },
|
||||
];
|
||||
|
||||
for (const candidate of commandAttempts) {
|
||||
const run = await spawnSafe(candidate.cmd, candidate.args, {
|
||||
cwd: nerveRoot,
|
||||
env: null,
|
||||
timeoutMs: 600_000,
|
||||
dryRun: false,
|
||||
});
|
||||
if (!run.ok) {
|
||||
continue;
|
||||
}
|
||||
const text = `${run.value.stdout}\n${run.value.stderr}`;
|
||||
const branch = text.match(/^BRANCH=(.*)$/m)?.[1]?.trim() ?? null;
|
||||
const commitHash = text.match(/^COMMIT=(.*)$/m)?.[1]?.trim() ?? null;
|
||||
const pushedText = text.match(/^PUSHED=(.*)$/m)?.[1]?.trim().toLowerCase() ?? "unknown";
|
||||
const pushed = pushedText === "true" ? true : pushedText === "false" ? false : null;
|
||||
return {
|
||||
invoked: true,
|
||||
success: true,
|
||||
branch: branch && branch.length > 0 ? branch : null,
|
||||
commitHash: commitHash && commitHash.length > 0 ? commitHash : null,
|
||||
pushed,
|
||||
log: text.slice(0, 20_000),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
const fallback = await cursorAgent({
|
||||
prompt: `Run this git committer task in repository ${nerveRoot}:\n\n${task}`,
|
||||
mode: "default",
|
||||
cwd: nerveRoot,
|
||||
env: null,
|
||||
timeoutMs: null,
|
||||
dryRun: false,
|
||||
});
|
||||
if (!fallback.ok) {
|
||||
return {
|
||||
invoked: true,
|
||||
success: false,
|
||||
branch: null,
|
||||
commitHash: null,
|
||||
pushed: null,
|
||||
log: "",
|
||||
error: `hermes and fallback both failed: ${formatSpawnFailure(fallback.error)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const out = fallback.value;
|
||||
const branch = out.match(/(?:branch|BRANCH)\s*[:=]\s*([^\s]+)/)?.[1] ?? null;
|
||||
const commitHash = out.match(/[a-f0-9]{7,40}/)?.[0] ?? null;
|
||||
return {
|
||||
invoked: true,
|
||||
success: true,
|
||||
branch,
|
||||
commitHash,
|
||||
pushed: out.toLowerCase().includes("push") ? true : null,
|
||||
log: out.slice(0, 20_000),
|
||||
error: null,
|
||||
};
|
||||
function writeLog(path: string, content: string): void {
|
||||
mkdirSync(join(path, ".."), { recursive: true });
|
||||
writeFileSync(path, content, "utf-8");
|
||||
}
|
||||
|
||||
export function buildCommitterRole({ nerveRoot }: BuildCommitterDeps): Role<CommitterMeta> {
|
||||
return async (start, messages) => {
|
||||
return async (start, _messages) => {
|
||||
const dry = isDryRun(start);
|
||||
const planner = lastMetaForRole<PlannerMeta>(messages, "planner");
|
||||
const tester = lastMetaForRole<TesterMeta>(messages, "tester");
|
||||
const workflowName = inferWorkflowName(messages);
|
||||
|
||||
const skipMeta: CommitterMeta = {
|
||||
invoked: false,
|
||||
success: false,
|
||||
branch: null,
|
||||
commitHash: null,
|
||||
pushed: null,
|
||||
log: "",
|
||||
error: null,
|
||||
};
|
||||
|
||||
if (planner === null || tester === null || workflowName.length === 0) {
|
||||
return {
|
||||
content: "committer skipped: missing planner/tester/workflowName context",
|
||||
meta: { ...skipMeta, error: "missing committer context" },
|
||||
} satisfies RoleResult<CommitterMeta>;
|
||||
}
|
||||
|
||||
if (!tester.passed) {
|
||||
return {
|
||||
content: "committer skipped: tester not passed",
|
||||
meta: { ...skipMeta, error: "tester not passed" },
|
||||
} satisfies RoleResult<CommitterMeta>;
|
||||
}
|
||||
const file = logPath(nerveRoot);
|
||||
|
||||
if (dry) {
|
||||
writeLog(file, "[dry-run] committer skipped\n");
|
||||
return {
|
||||
content: "[dry-run] skipped hermes committer",
|
||||
meta: {
|
||||
invoked: true,
|
||||
success: true,
|
||||
branch: "wf/dry-run",
|
||||
commitHash: null,
|
||||
pushed: null,
|
||||
log: "[dry-run] skipped hermes committer",
|
||||
error: null,
|
||||
},
|
||||
content: `[dry-run] committer skipped — log: ${file}`,
|
||||
meta: { success: true },
|
||||
} satisfies RoleResult<CommitterMeta>;
|
||||
}
|
||||
|
||||
const task = committerPrompt({
|
||||
nerveRoot,
|
||||
workflowName,
|
||||
userPrompt: planner.userPrompt,
|
||||
testerReason: tester.reason,
|
||||
});
|
||||
const lines: string[] = [];
|
||||
let success = true;
|
||||
|
||||
const committed = await runHermesCommitter(task, nerveRoot);
|
||||
const run = async (cmd: string, args: string[]): Promise<boolean> => {
|
||||
const r = await spawnSafe(cmd, args, { cwd: nerveRoot, env: null, timeoutMs: 60_000, dryRun: false });
|
||||
if (r.ok) {
|
||||
lines.push(`$ ${cmd} ${args.join(" ")}`);
|
||||
if (r.value.stdout) lines.push(r.value.stdout);
|
||||
if (r.value.stderr) lines.push(r.value.stderr);
|
||||
lines.push("");
|
||||
return true;
|
||||
}
|
||||
const e = r.error;
|
||||
lines.push(`$ ${cmd} ${args.join(" ")} — FAILED`);
|
||||
if (e.kind === "non_zero_exit") {
|
||||
lines.push(`exit ${e.exitCode}`);
|
||||
if (e.stdout) lines.push(e.stdout);
|
||||
if (e.stderr) lines.push(e.stderr);
|
||||
} else if (e.kind === "timeout") {
|
||||
lines.push("timeout");
|
||||
if (e.stdout) lines.push(e.stdout);
|
||||
if (e.stderr) lines.push(e.stderr);
|
||||
} else {
|
||||
lines.push(e.message);
|
||||
}
|
||||
lines.push("");
|
||||
return false;
|
||||
};
|
||||
|
||||
await run("git", ["add", "-A"]);
|
||||
const committed = await run("git", ["commit", "-m", "chore: add generated workflow"]);
|
||||
if (!committed) {
|
||||
success = false;
|
||||
} else {
|
||||
const pushed = await run("git", ["push"]);
|
||||
if (!pushed) success = false;
|
||||
}
|
||||
|
||||
const log = lines.join("\n");
|
||||
writeLog(file, log);
|
||||
|
||||
const summary = success ? "committed and pushed" : "commit/push failed — see log";
|
||||
return {
|
||||
content: committed.success
|
||||
? committed.log
|
||||
: `committer failed: ${committed.error ?? "unknown"}`,
|
||||
meta: committed,
|
||||
content: `committer: ${summary}\nLog: ${file}`,
|
||||
meta: { success },
|
||||
} satisfies RoleResult<CommitterMeta>;
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,142 +1,23 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Role, RoleResult } from "@uncaged/nerve-core";
|
||||
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
|
||||
import { isDryRun, llmExtract, nerveAgentContext, readNerveYaml } from "@uncaged/nerve-workflow-utils";
|
||||
import { z } from "zod";
|
||||
import { createCursorRole } from "@uncaged/nerve-workflow-utils";
|
||||
import { plannerPrompt } from "./prompt.js";
|
||||
|
||||
const roleSchema = z
|
||||
.object({
|
||||
name: z.string().default(""),
|
||||
goal: z.string().default(""),
|
||||
io: z.string().default(""),
|
||||
})
|
||||
.default({ name: "", goal: "", io: "" });
|
||||
import { z } from "zod";
|
||||
|
||||
export const plannerMetaSchema = z.object({
|
||||
userPrompt: z.string().default(""),
|
||||
workflowName: z
|
||||
.string()
|
||||
.default("")
|
||||
.describe("kebab-case workflow name under workflows/, e.g. issue-fixer"),
|
||||
roles: z.array(roleSchema).default([]),
|
||||
flowTransitions: z.preprocess((v) => (Array.isArray(v) ? v.join("\n") : v), z.string().default("")),
|
||||
validationLoopsDesign: z.preprocess(
|
||||
(v) => (Array.isArray(v) ? v.join("\n") : v),
|
||||
z.string().default(""),
|
||||
),
|
||||
externalDeps: z.preprocess(
|
||||
(v) => (Array.isArray(v) ? v.join(", ") : v),
|
||||
z.string().default(""),
|
||||
),
|
||||
dataFlow: z.preprocess((v) => (Array.isArray(v) ? v.join("\n") : v), z.string().default("")),
|
||||
planMarkdown: z.preprocess(
|
||||
(v) => (Array.isArray(v) ? v.join("\n") : v),
|
||||
z.string().default(""),
|
||||
),
|
||||
ready: z.boolean().describe("true if requirements are clear and a workflow can be implemented"),
|
||||
});
|
||||
|
||||
export type PlannerMeta = z.infer<typeof plannerMetaSchema>;
|
||||
|
||||
export type BuildPlannerDeps = {
|
||||
provider: LlmProvider;
|
||||
nerveRoot: string;
|
||||
workflowsDir: string;
|
||||
cwd: string;
|
||||
};
|
||||
|
||||
function getNerveYaml(nerveRoot: string): string {
|
||||
const result = readNerveYaml({ nerveRoot });
|
||||
return result.ok ? result.value : "# nerve.yaml unavailable";
|
||||
}
|
||||
|
||||
function getSenseGeneratorReference(workflowsDir: string): string {
|
||||
const p = join(workflowsDir, "sense-generator", "index.ts");
|
||||
if (!existsSync(p)) {
|
||||
return "(missing workflows/sense-generator/index.ts)";
|
||||
}
|
||||
return readFileSync(p, "utf-8");
|
||||
}
|
||||
|
||||
export function buildPlannerRole({
|
||||
provider,
|
||||
nerveRoot,
|
||||
workflowsDir,
|
||||
}: BuildPlannerDeps): Role<PlannerMeta> {
|
||||
return async (start, _messages) => {
|
||||
const dry = isDryRun(start);
|
||||
const userPrompt = start.content;
|
||||
|
||||
const messages = plannerPrompt({
|
||||
nerveAgentContext,
|
||||
userPrompt,
|
||||
nerveRoot,
|
||||
workflowsDir,
|
||||
senseGeneratorReference: getSenseGeneratorReference(workflowsDir),
|
||||
nerveYaml: getNerveYaml(nerveRoot),
|
||||
});
|
||||
|
||||
const extracted = await llmExtract({
|
||||
text: messages.map((m) => m.content).join("\n"),
|
||||
schema: plannerMetaSchema,
|
||||
provider,
|
||||
dryRun: dry,
|
||||
});
|
||||
|
||||
const emptyMeta: PlannerMeta = {
|
||||
userPrompt,
|
||||
workflowName: "",
|
||||
roles: [],
|
||||
flowTransitions: "",
|
||||
validationLoopsDesign: "",
|
||||
externalDeps: "",
|
||||
dataFlow: "",
|
||||
planMarkdown: "",
|
||||
};
|
||||
|
||||
if (!extracted.ok) {
|
||||
return {
|
||||
content: `[planner] llmExtract failed: ${JSON.stringify(extracted.error)}`,
|
||||
meta: emptyMeta,
|
||||
} satisfies RoleResult<PlannerMeta>;
|
||||
}
|
||||
|
||||
const value = extracted.value;
|
||||
const planMarkdown =
|
||||
value.planMarkdown.length > 0
|
||||
? value.planMarkdown
|
||||
: [
|
||||
`# Workflow Plan`,
|
||||
`- workflowName: ${value.workflowName}`,
|
||||
``,
|
||||
`## Roles`,
|
||||
...value.roles.map((r) => `- ${r.name}: ${r.goal} (${r.io})`),
|
||||
``,
|
||||
`## Flow Transitions`,
|
||||
value.flowTransitions,
|
||||
``,
|
||||
`## Validation Loops`,
|
||||
value.validationLoopsDesign,
|
||||
``,
|
||||
`## External Dependencies`,
|
||||
value.externalDeps,
|
||||
``,
|
||||
`## Data Flow`,
|
||||
value.dataFlow,
|
||||
].join("\n");
|
||||
|
||||
return {
|
||||
content: planMarkdown,
|
||||
meta: {
|
||||
userPrompt,
|
||||
workflowName: value.workflowName,
|
||||
roles: value.roles,
|
||||
flowTransitions: value.flowTransitions,
|
||||
validationLoopsDesign: value.validationLoopsDesign,
|
||||
externalDeps: value.externalDeps,
|
||||
dataFlow: value.dataFlow,
|
||||
planMarkdown,
|
||||
},
|
||||
} satisfies RoleResult<PlannerMeta>;
|
||||
};
|
||||
export function buildPlannerRole({ provider, cwd }: BuildPlannerDeps) {
|
||||
return createCursorRole<PlannerMeta>({
|
||||
cwd,
|
||||
mode: "ask",
|
||||
prompt: async (threadId) => plannerPrompt({ threadId }),
|
||||
extract: { provider, schema: plannerMetaSchema },
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
\`\`\``;
|
||||
}
|
||||
|
||||
@ -1,153 +1,20 @@
|
||||
import type { Role, RoleResult, WorkflowMessage } from "@uncaged/nerve-core";
|
||||
import type { SpawnError } from "@uncaged/nerve-workflow-utils";
|
||||
import { cursorAgent, isDryRun } from "@uncaged/nerve-workflow-utils";
|
||||
import { z } from "zod";
|
||||
import type { CoderMeta } from "../coder/index.js";
|
||||
import type { PlannerMeta } from "../planner/index.js";
|
||||
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
|
||||
import { createHermesRole } from "@uncaged/nerve-workflow-utils";
|
||||
import { testerPrompt } from "./prompt.js";
|
||||
import { z } from "zod";
|
||||
|
||||
export const testerMetaSchema = z.object({
|
||||
workflowName: z.string().default(""),
|
||||
attempt: z.number().default(1),
|
||||
passed: z.boolean().default(false),
|
||||
dryRunLog: z.string().default(""),
|
||||
reason: z.string().default(""),
|
||||
passed: z.boolean().describe("true if all validation checks passed"),
|
||||
});
|
||||
|
||||
export type TesterMeta = z.infer<typeof testerMetaSchema>;
|
||||
|
||||
export type BuildTesterDeps = {
|
||||
nerveRoot: string;
|
||||
provider: LlmProvider;
|
||||
};
|
||||
|
||||
function formatSpawnFailure(error: SpawnError): string {
|
||||
if (error.kind === "spawn_failed") {
|
||||
return error.message;
|
||||
}
|
||||
if (error.kind === "timeout") {
|
||||
return `timeout stdout=${error.stdout.slice(0, 300)} stderr=${error.stderr.slice(0, 300)}`;
|
||||
}
|
||||
return `exit ${error.exitCode} stderr=${error.stderr.slice(0, 500)}`;
|
||||
}
|
||||
|
||||
function lastMetaForRole<M>(messages: WorkflowMessage[], role: string): M | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === role) {
|
||||
return messages[i].meta as M;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildTesterRole({ nerveRoot }: BuildTesterDeps): Role<TesterMeta> {
|
||||
return async (start, messages) => {
|
||||
const dry = isDryRun(start);
|
||||
const plannerMeta = lastMetaForRole<PlannerMeta>(messages, "planner");
|
||||
const coderMeta = lastMetaForRole<CoderMeta>(messages, "coder");
|
||||
const attempt = messages.filter((m) => m.role === "tester").length + 1;
|
||||
|
||||
if (plannerMeta === null || coderMeta === null) {
|
||||
return {
|
||||
content: "tester cannot continue: missing planner/coder output",
|
||||
meta: {
|
||||
workflowName: "",
|
||||
attempt,
|
||||
passed: false,
|
||||
dryRunLog: "",
|
||||
reason: "missing planner/coder output",
|
||||
},
|
||||
} satisfies RoleResult<TesterMeta>;
|
||||
}
|
||||
|
||||
if (!coderMeta.lintPassed || !coderMeta.buildPassed) {
|
||||
return {
|
||||
content: "tester blocked: coder has not passed lint+build",
|
||||
meta: {
|
||||
workflowName: coderMeta.workflowName,
|
||||
attempt,
|
||||
passed: false,
|
||||
dryRunLog: `${coderMeta.lintLog}\n\n${coderMeta.buildLog}`,
|
||||
reason: "coder did not pass lint+build",
|
||||
},
|
||||
} satisfies RoleResult<TesterMeta>;
|
||||
}
|
||||
|
||||
if (dry) {
|
||||
return {
|
||||
content: "PASS — dry-run mode",
|
||||
meta: {
|
||||
workflowName: coderMeta.workflowName,
|
||||
attempt,
|
||||
passed: true,
|
||||
dryRunLog: "[dry-run] tester skipped external checks",
|
||||
reason: "dry-run mode",
|
||||
},
|
||||
} satisfies RoleResult<TesterMeta>;
|
||||
}
|
||||
|
||||
const prompt = testerPrompt({
|
||||
workflowName: coderMeta.workflowName,
|
||||
plannerSpec: {
|
||||
roles: plannerMeta.roles,
|
||||
flowTransitions: plannerMeta.flowTransitions,
|
||||
validationLoopsDesign: plannerMeta.validationLoopsDesign,
|
||||
externalDeps: plannerMeta.externalDeps,
|
||||
dataFlow: plannerMeta.dataFlow,
|
||||
},
|
||||
coderOutput: coderMeta.cursorOutput,
|
||||
nerveRoot,
|
||||
});
|
||||
|
||||
const run = await cursorAgent({
|
||||
prompt,
|
||||
mode: "ask",
|
||||
cwd: nerveRoot,
|
||||
env: null,
|
||||
timeoutMs: null,
|
||||
dryRun: false,
|
||||
});
|
||||
|
||||
if (!run.ok) {
|
||||
return {
|
||||
content: "tester agent failed",
|
||||
meta: {
|
||||
workflowName: coderMeta.workflowName,
|
||||
attempt,
|
||||
passed: false,
|
||||
dryRunLog: "",
|
||||
reason: `tester agent failed: ${formatSpawnFailure(run.error)}`,
|
||||
},
|
||||
} satisfies RoleResult<TesterMeta>;
|
||||
}
|
||||
|
||||
const text = run.value.trim();
|
||||
const pass = text.startsWith("PASS|");
|
||||
const fail = text.startsWith("FAIL|");
|
||||
if (!pass && !fail) {
|
||||
return {
|
||||
content: "tester format invalid",
|
||||
meta: {
|
||||
workflowName: coderMeta.workflowName,
|
||||
attempt,
|
||||
passed: false,
|
||||
dryRunLog: text,
|
||||
reason: "tester format invalid",
|
||||
},
|
||||
} satisfies RoleResult<TesterMeta>;
|
||||
}
|
||||
|
||||
const parts = text.split("|");
|
||||
const reason = parts[1] ?? "no reason";
|
||||
const log = parts.slice(2).join("|").trim();
|
||||
return {
|
||||
content: `${pass ? "PASS" : "FAIL"} — ${reason}`,
|
||||
meta: {
|
||||
workflowName: coderMeta.workflowName,
|
||||
attempt,
|
||||
passed: pass,
|
||||
dryRunLog: log,
|
||||
reason,
|
||||
},
|
||||
} satisfies RoleResult<TesterMeta>;
|
||||
};
|
||||
export function buildTesterRole({ provider }: BuildTesterDeps) {
|
||||
return createHermesRole<TesterMeta>({
|
||||
prompt: async (threadId) => testerPrompt({ threadId }),
|
||||
extract: { provider, schema: testerMetaSchema },
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,34 +1,27 @@
|
||||
export type TesterPromptParams = {
|
||||
workflowName: string;
|
||||
plannerSpec: object;
|
||||
coderOutput: string;
|
||||
nerveRoot: string;
|
||||
};
|
||||
export function testerPrompt({ threadId }: { threadId: string }): string {
|
||||
return `You are testing a newly generated Nerve workflow end-to-end.
|
||||
|
||||
export function testerPrompt({
|
||||
workflowName,
|
||||
plannerSpec,
|
||||
coderOutput,
|
||||
nerveRoot: _nerveRoot,
|
||||
}: TesterPromptParams): string {
|
||||
return `You are testing a generated Nerve workflow by doing a dry-run review.
|
||||
Read the workflow thread for context: \`nerve thread ${threadId}\`
|
||||
Read the nerve-dev skill for expected file structure: \`cat node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\`
|
||||
|
||||
Workflow: ${workflowName}
|
||||
Get the workflow name from the thread (the planner's output).
|
||||
|
||||
Planner specification:
|
||||
${JSON.stringify(plannerSpec, null, 2)}
|
||||
Verify the full lifecycle:
|
||||
1. Check all required workflow files exist: index.ts, package.json, tsconfig.json
|
||||
2. Check nerve.yaml has the workflow config
|
||||
3. Run \`nerve workflow list\` — confirm the workflow appears
|
||||
4. Run \`pnpm build\` inside the workflow directory — must succeed
|
||||
5. Run \`nerve workflow dry-run <workflow-name>\` — should complete without error
|
||||
6. If any step fails, include the relevant error output
|
||||
|
||||
Coder output summary:
|
||||
${coderOutput.slice(0, 6000)}
|
||||
Output a clear summary: what you checked, what passed, what failed, and why.
|
||||
|
||||
Required checks:
|
||||
1) Verify role transitions are coherent and terminates to END.
|
||||
2) Verify generated workflow adheres to planner intent.
|
||||
3) Verify retry loops are explicit for recoverable failures.
|
||||
4) Verify no obvious runtime-breaking issue in generated index.ts.
|
||||
|
||||
Return exactly:
|
||||
PASS|<reason>|<compact markdown log>
|
||||
End your response with a JSON block:
|
||||
\`\`\`json
|
||||
{ "passed": true }
|
||||
\`\`\`
|
||||
or
|
||||
FAIL|<reason>|<compact markdown log>`;
|
||||
\`\`\`json
|
||||
{ "passed": false }
|
||||
\`\`\``;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user