refactor(workflow-generator): multi-file DIP + Role Factory + esbuild bundle
- Split 500-line monolith into roles/{planner,coder,tester,committer}/
- Each role: index.ts (build function) + prompt.ts (pure function)
- Use createCursorRole/createLlmRole/createHermesRole factories
- DIP: env vars read in index.ts, injected via build.ts
- esbuild bundle to dist/index.js (24kb)
- Moderator logic preserved: planner→coder→tester→committer with retries
Fixes xiaoju/nerve-workspace#3
This commit is contained in:
parent
4cf10ad7bf
commit
a469f30b42
31
workflows/workflow-generator/build.ts
Normal file
31
workflows/workflow-generator/build.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { join } from "node:path";
|
||||
import type { WorkflowDefinition } from "@uncaged/nerve-core";
|
||||
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
|
||||
import { buildPlannerRole } from "./roles/planner/index.js";
|
||||
import { buildCoderRole } from "./roles/coder/index.js";
|
||||
import { buildTesterRole } from "./roles/tester/index.js";
|
||||
import { buildCommitterRole } from "./roles/committer/index.js";
|
||||
import { moderator } from "./moderator.js";
|
||||
import type { WorkflowMeta } from "./moderator.js";
|
||||
|
||||
export type BuildWorkflowGeneratorDeps = {
|
||||
provider: LlmProvider;
|
||||
nerveRoot: string;
|
||||
};
|
||||
|
||||
export function buildWorkflowGenerator({
|
||||
provider,
|
||||
nerveRoot,
|
||||
}: BuildWorkflowGeneratorDeps): WorkflowDefinition<WorkflowMeta> {
|
||||
const workflowsDir = join(nerveRoot, "workflows");
|
||||
return {
|
||||
name: "workflow-generator",
|
||||
roles: {
|
||||
planner: buildPlannerRole({ provider, nerveRoot, workflowsDir }),
|
||||
coder: buildCoderRole({ nerveRoot, workflowsDir }),
|
||||
tester: buildTesterRole({ nerveRoot }),
|
||||
committer: buildCommitterRole({ nerveRoot }),
|
||||
},
|
||||
moderator,
|
||||
};
|
||||
}
|
||||
846
workflows/workflow-generator/dist/index.js
vendored
Normal file
846
workflows/workflow-generator/dist/index.js
vendored
Normal file
@ -0,0 +1,846 @@
|
||||
// index.ts
|
||||
import { join as join4 } from "node:path";
|
||||
|
||||
// build.ts
|
||||
import { join as join3 } from "node:path";
|
||||
|
||||
// roles/planner/index.ts
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { isDryRun, llmExtract, nerveAgentContext, readNerveYaml } from "@uncaged/nerve-workflow-utils";
|
||||
import { z } from "zod";
|
||||
|
||||
// roles/planner/prompt.ts
|
||||
function plannerPrompt({
|
||||
nerveAgentContext: nerveAgentContext2,
|
||||
userPrompt,
|
||||
nerveRoot,
|
||||
workflowsDir,
|
||||
senseGeneratorReference,
|
||||
nerveYaml
|
||||
}) {
|
||||
const content = `Design a Nerve workflow plan from this request.
|
||||
|
||||
${nerveAgentContext2}
|
||||
|
||||
User request:
|
||||
${userPrompt}
|
||||
|
||||
Target root: ${nerveRoot}
|
||||
Workflow dir root: ${workflowsDir}
|
||||
|
||||
Reference structure:
|
||||
\`\`\`ts
|
||||
${senseGeneratorReference.slice(0, 18e3)}
|
||||
\`\`\`
|
||||
|
||||
Current nerve.yaml:
|
||||
\`\`\`yaml
|
||||
${nerveYaml}
|
||||
\`\`\`
|
||||
|
||||
Produce a complete markdown plan that includes:
|
||||
- workflow name
|
||||
- roles list
|
||||
- flow/transitions
|
||||
- validation loops design
|
||||
- external deps
|
||||
- data flow`;
|
||||
return [{ role: "user", content }];
|
||||
}
|
||||
|
||||
// roles/planner/index.ts
|
||||
var roleSchema = z.object({
|
||||
name: z.string().default(""),
|
||||
goal: z.string().default(""),
|
||||
io: z.string().default("")
|
||||
}).default({ name: "", goal: "", io: "" });
|
||||
var plannerMetaSchema = z.object({
|
||||
userPrompt: z.string().default(""),
|
||||
workflowName: z.string().default("").describe("kebab-case workflow name under workflows/, e.g. issue-fixer"),
|
||||
roles: z.array(roleSchema).default([]),
|
||||
flowTransitions: z.preprocess((v) => Array.isArray(v) ? v.join("\n") : v, z.string().default("")),
|
||||
validationLoopsDesign: z.preprocess(
|
||||
(v) => Array.isArray(v) ? v.join("\n") : v,
|
||||
z.string().default("")
|
||||
),
|
||||
externalDeps: z.preprocess(
|
||||
(v) => Array.isArray(v) ? v.join(", ") : v,
|
||||
z.string().default("")
|
||||
),
|
||||
dataFlow: z.preprocess((v) => Array.isArray(v) ? v.join("\n") : v, z.string().default("")),
|
||||
planMarkdown: z.preprocess(
|
||||
(v) => Array.isArray(v) ? v.join("\n") : v,
|
||||
z.string().default("")
|
||||
)
|
||||
});
|
||||
function getNerveYaml(nerveRoot) {
|
||||
const result = readNerveYaml({ nerveRoot });
|
||||
return result.ok ? result.value : "# nerve.yaml unavailable";
|
||||
}
|
||||
function getSenseGeneratorReference(workflowsDir) {
|
||||
const p = join(workflowsDir, "sense-generator", "index.ts");
|
||||
if (!existsSync(p)) {
|
||||
return "(missing workflows/sense-generator/index.ts)";
|
||||
}
|
||||
return readFileSync(p, "utf-8");
|
||||
}
|
||||
function buildPlannerRole({
|
||||
provider,
|
||||
nerveRoot,
|
||||
workflowsDir
|
||||
}) {
|
||||
return async (start, _messages) => {
|
||||
const dry = isDryRun(start);
|
||||
const userPrompt = start.content;
|
||||
const messages = plannerPrompt({
|
||||
nerveAgentContext,
|
||||
userPrompt,
|
||||
nerveRoot,
|
||||
workflowsDir,
|
||||
senseGeneratorReference: getSenseGeneratorReference(workflowsDir),
|
||||
nerveYaml: getNerveYaml(nerveRoot)
|
||||
});
|
||||
const extracted = await llmExtract({
|
||||
text: messages.map((m) => m.content).join("\n"),
|
||||
schema: plannerMetaSchema,
|
||||
provider,
|
||||
dryRun: dry
|
||||
});
|
||||
const emptyMeta = {
|
||||
userPrompt,
|
||||
workflowName: "",
|
||||
roles: [],
|
||||
flowTransitions: "",
|
||||
validationLoopsDesign: "",
|
||||
externalDeps: "",
|
||||
dataFlow: "",
|
||||
planMarkdown: ""
|
||||
};
|
||||
if (!extracted.ok) {
|
||||
return {
|
||||
content: `[planner] llmExtract failed: ${JSON.stringify(extracted.error)}`,
|
||||
meta: emptyMeta
|
||||
};
|
||||
}
|
||||
const value = extracted.value;
|
||||
const planMarkdown = value.planMarkdown.length > 0 ? value.planMarkdown : [
|
||||
`# Workflow Plan`,
|
||||
`- workflowName: ${value.workflowName}`,
|
||||
``,
|
||||
`## Roles`,
|
||||
...value.roles.map((r) => `- ${r.name}: ${r.goal} (${r.io})`),
|
||||
``,
|
||||
`## Flow Transitions`,
|
||||
value.flowTransitions,
|
||||
``,
|
||||
`## Validation Loops`,
|
||||
value.validationLoopsDesign,
|
||||
``,
|
||||
`## External Dependencies`,
|
||||
value.externalDeps,
|
||||
``,
|
||||
`## Data Flow`,
|
||||
value.dataFlow
|
||||
].join("\n");
|
||||
return {
|
||||
content: planMarkdown,
|
||||
meta: {
|
||||
userPrompt,
|
||||
workflowName: value.workflowName,
|
||||
roles: value.roles,
|
||||
flowTransitions: value.flowTransitions,
|
||||
validationLoopsDesign: value.validationLoopsDesign,
|
||||
externalDeps: value.externalDeps,
|
||||
dataFlow: value.dataFlow,
|
||||
planMarkdown
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// roles/coder/index.ts
|
||||
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
|
||||
import { join as join2 } from "node:path";
|
||||
import { cursorAgent, isDryRun as isDryRun2, spawnSafe } from "@uncaged/nerve-workflow-utils";
|
||||
import { z as z2 } from "zod";
|
||||
|
||||
// roles/coder/prompt.ts
|
||||
function coderPrompt({
|
||||
workflowsDir,
|
||||
wfName,
|
||||
planMarkdown,
|
||||
plannerStructured,
|
||||
feedback,
|
||||
nerveRoot
|
||||
}) {
|
||||
return `Implement a Nerve workflow package under ${workflowsDir}/${wfName}/.
|
||||
|
||||
Planner output:
|
||||
${planMarkdown}
|
||||
|
||||
Structured planner fields:
|
||||
${JSON.stringify(plannerStructured, null, 2)}
|
||||
${feedback}
|
||||
|
||||
Required files:
|
||||
1) ${workflowsDir}/${wfName}/index.ts
|
||||
2) ${workflowsDir}/${wfName}/package.json
|
||||
3) ${workflowsDir}/${wfName}/tsconfig.json
|
||||
4) update ${nerveRoot}/nerve.yaml with workflows.${wfName}
|
||||
|
||||
Rules:
|
||||
- keep WorkflowDefinition<WorkflowMeta> pattern
|
||||
- no dynamic import()
|
||||
- use types (not interfaces)
|
||||
- include retry-aware moderator routing
|
||||
- write compile-ready TypeScript`;
|
||||
}
|
||||
|
||||
// roles/coder/index.ts
|
||||
var coderMetaSchema = z2.object({
|
||||
workflowName: z2.string().default(""),
|
||||
attempt: z2.number().default(1),
|
||||
files: z2.object({
|
||||
indexTs: z2.boolean().default(false),
|
||||
packageJson: z2.boolean().default(false),
|
||||
tsconfigJson: z2.boolean().default(false)
|
||||
}).default({ indexTs: false, packageJson: false, tsconfigJson: false }),
|
||||
lintPassed: z2.boolean().default(false),
|
||||
buildPassed: z2.boolean().default(false),
|
||||
lintLog: z2.string().default(""),
|
||||
buildLog: z2.string().default(""),
|
||||
cursorOutput: z2.string().default(""),
|
||||
reason: z2.string().nullable().default(null)
|
||||
});
|
||||
function formatSpawnFailure(error) {
|
||||
if (error.kind === "spawn_failed") {
|
||||
return error.message;
|
||||
}
|
||||
if (error.kind === "timeout") {
|
||||
return `timeout stdout=${error.stdout.slice(0, 300)} stderr=${error.stderr.slice(0, 300)}`;
|
||||
}
|
||||
return `exit ${error.exitCode} stderr=${error.stderr.slice(0, 500)}`;
|
||||
}
|
||||
function scanGeneratedCodePitfalls(source) {
|
||||
const issues = [];
|
||||
if (/\bawait\s+import\s*\(/.test(source)) {
|
||||
issues.push("Found await import() in generated workflow code");
|
||||
}
|
||||
if (/\bimport\s*\(\s*["'`]/.test(source) && !source.includes("Dynamic import required")) {
|
||||
issues.push("Found undocumented dynamic import() call");
|
||||
}
|
||||
if (!/\bexport\s+default\s+/.test(source)) {
|
||||
issues.push("Missing default export of WorkflowDefinition");
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
async function runLintAndBuild(workflowDir, dry) {
|
||||
const lintRun = await spawnSafe("pnpm", ["run", "check"], {
|
||||
cwd: workflowDir,
|
||||
env: null,
|
||||
timeoutMs: 3e5,
|
||||
dryRun: dry
|
||||
});
|
||||
if (!lintRun.ok) {
|
||||
return {
|
||||
lintPassed: false,
|
||||
buildPassed: false,
|
||||
lintLog: formatSpawnFailure(lintRun.error),
|
||||
buildLog: "",
|
||||
reason: `lint failed: ${formatSpawnFailure(lintRun.error)}`
|
||||
};
|
||||
}
|
||||
const lintLog = lintRun.value.stderr.trim() || lintRun.value.stdout.trim() || "(no output)";
|
||||
const tscRun = await spawnSafe("npx", ["tsc", "--noEmit"], {
|
||||
cwd: workflowDir,
|
||||
env: null,
|
||||
timeoutMs: 3e5,
|
||||
dryRun: dry
|
||||
});
|
||||
if (!tscRun.ok) {
|
||||
return {
|
||||
lintPassed: true,
|
||||
buildPassed: false,
|
||||
lintLog,
|
||||
buildLog: formatSpawnFailure(tscRun.error),
|
||||
reason: `build failed: ${formatSpawnFailure(tscRun.error)}`
|
||||
};
|
||||
}
|
||||
const buildLog = tscRun.value.stderr.trim() || tscRun.value.stdout.trim() || "(no output)";
|
||||
return { lintPassed: true, buildPassed: true, lintLog, buildLog, reason: null };
|
||||
}
|
||||
function lastMetaForRole(messages, role) {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === role) {
|
||||
return messages[i].meta;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function buildCoderRole({ nerveRoot, workflowsDir }) {
|
||||
return async (start, messages) => {
|
||||
const dry = isDryRun2(start);
|
||||
const plannerMeta = lastMetaForRole(messages, "planner");
|
||||
const previousTester = lastMetaForRole(messages, "tester");
|
||||
const attempt = messages.filter((m) => m.role === "coder").length + 1;
|
||||
if (plannerMeta === null || plannerMeta.workflowName.trim().length === 0) {
|
||||
return {
|
||||
content: "coder cannot continue: missing planner output",
|
||||
meta: {
|
||||
workflowName: "",
|
||||
attempt,
|
||||
files: { indexTs: false, packageJson: false, tsconfigJson: false },
|
||||
lintPassed: false,
|
||||
buildPassed: false,
|
||||
lintLog: "",
|
||||
buildLog: "",
|
||||
cursorOutput: "",
|
||||
reason: "missing planner output"
|
||||
}
|
||||
};
|
||||
}
|
||||
const wfName = plannerMeta.workflowName.trim();
|
||||
const feedback = previousTester !== null && previousTester.passed === false ? `
|
||||
|
||||
Previous tester failure to fix:
|
||||
${previousTester.reason}
|
||||
${previousTester.dryRunLog}
|
||||
` : "";
|
||||
const prompt = coderPrompt({
|
||||
workflowsDir,
|
||||
wfName,
|
||||
planMarkdown: plannerMeta.planMarkdown,
|
||||
plannerStructured: {
|
||||
workflowName: plannerMeta.workflowName,
|
||||
roles: plannerMeta.roles,
|
||||
flowTransitions: plannerMeta.flowTransitions,
|
||||
validationLoopsDesign: plannerMeta.validationLoopsDesign,
|
||||
externalDeps: plannerMeta.externalDeps,
|
||||
dataFlow: plannerMeta.dataFlow
|
||||
},
|
||||
feedback,
|
||||
nerveRoot
|
||||
});
|
||||
const agentRun = await cursorAgent({
|
||||
prompt,
|
||||
mode: "default",
|
||||
cwd: nerveRoot,
|
||||
env: null,
|
||||
timeoutMs: null,
|
||||
dryRun: dry
|
||||
});
|
||||
const workflowDir = join2(workflowsDir, wfName);
|
||||
const files = {
|
||||
indexTs: existsSync2(join2(workflowDir, "index.ts")),
|
||||
packageJson: existsSync2(join2(workflowDir, "package.json")),
|
||||
tsconfigJson: existsSync2(join2(workflowDir, "tsconfig.json"))
|
||||
};
|
||||
const missing = [
|
||||
files.indexTs ? null : "index.ts",
|
||||
files.packageJson ? null : "package.json",
|
||||
files.tsconfigJson ? null : "tsconfig.json"
|
||||
].filter((x) => x !== null);
|
||||
if (!agentRun.ok) {
|
||||
return {
|
||||
content: `coder failed: ${formatSpawnFailure(agentRun.error)}`,
|
||||
meta: {
|
||||
workflowName: wfName,
|
||||
attempt,
|
||||
files,
|
||||
lintPassed: false,
|
||||
buildPassed: false,
|
||||
lintLog: "",
|
||||
buildLog: "",
|
||||
cursorOutput: "",
|
||||
reason: formatSpawnFailure(agentRun.error)
|
||||
}
|
||||
};
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
content: `coder failed: missing required files (${missing.join(", ")})`,
|
||||
meta: {
|
||||
workflowName: wfName,
|
||||
attempt,
|
||||
files,
|
||||
lintPassed: false,
|
||||
buildPassed: false,
|
||||
lintLog: "",
|
||||
buildLog: "",
|
||||
cursorOutput: agentRun.value,
|
||||
reason: `missing files: ${missing.join(", ")}`
|
||||
}
|
||||
};
|
||||
}
|
||||
const source = readFileSync2(join2(workflowDir, "index.ts"), "utf-8");
|
||||
const pitfalls = scanGeneratedCodePitfalls(source);
|
||||
if (pitfalls.length > 0) {
|
||||
return {
|
||||
content: `coder static check failed:
|
||||
${pitfalls.join("\n")}`,
|
||||
meta: {
|
||||
workflowName: wfName,
|
||||
attempt,
|
||||
files,
|
||||
lintPassed: false,
|
||||
buildPassed: false,
|
||||
lintLog: pitfalls.join("\n"),
|
||||
buildLog: "",
|
||||
cursorOutput: agentRun.value,
|
||||
reason: pitfalls.join("; ")
|
||||
}
|
||||
};
|
||||
}
|
||||
const check = await runLintAndBuild(workflowDir, dry);
|
||||
const passed = check.lintPassed && check.buildPassed;
|
||||
return {
|
||||
content: passed ? `coder PASS: lint+build ok
|
||||
|
||||
${check.lintLog}
|
||||
|
||||
${check.buildLog}` : `coder FAIL: ${check.reason ?? "unknown error"}`,
|
||||
meta: {
|
||||
workflowName: wfName,
|
||||
attempt,
|
||||
files,
|
||||
lintPassed: check.lintPassed,
|
||||
buildPassed: check.buildPassed,
|
||||
lintLog: check.lintLog,
|
||||
buildLog: check.buildLog,
|
||||
cursorOutput: agentRun.value,
|
||||
reason: check.reason
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// roles/tester/index.ts
|
||||
import { cursorAgent as cursorAgent2, isDryRun as isDryRun3 } from "@uncaged/nerve-workflow-utils";
|
||||
import { z as z3 } from "zod";
|
||||
|
||||
// roles/tester/prompt.ts
|
||||
function testerPrompt({
|
||||
workflowName,
|
||||
plannerSpec,
|
||||
coderOutput,
|
||||
nerveRoot: _nerveRoot
|
||||
}) {
|
||||
return `You are testing a generated Nerve workflow by doing a dry-run review.
|
||||
|
||||
Workflow: ${workflowName}
|
||||
|
||||
Planner specification:
|
||||
${JSON.stringify(plannerSpec, null, 2)}
|
||||
|
||||
Coder output summary:
|
||||
${coderOutput.slice(0, 6e3)}
|
||||
|
||||
Required checks:
|
||||
1) Verify role transitions are coherent and terminates to END.
|
||||
2) Verify generated workflow adheres to planner intent.
|
||||
3) Verify retry loops are explicit for recoverable failures.
|
||||
4) Verify no obvious runtime-breaking issue in generated index.ts.
|
||||
|
||||
Return exactly:
|
||||
PASS|<reason>|<compact markdown log>
|
||||
or
|
||||
FAIL|<reason>|<compact markdown log>`;
|
||||
}
|
||||
|
||||
// roles/tester/index.ts
|
||||
var testerMetaSchema = z3.object({
|
||||
workflowName: z3.string().default(""),
|
||||
attempt: z3.number().default(1),
|
||||
passed: z3.boolean().default(false),
|
||||
dryRunLog: z3.string().default(""),
|
||||
reason: z3.string().default("")
|
||||
});
|
||||
function formatSpawnFailure2(error) {
|
||||
if (error.kind === "spawn_failed") {
|
||||
return error.message;
|
||||
}
|
||||
if (error.kind === "timeout") {
|
||||
return `timeout stdout=${error.stdout.slice(0, 300)} stderr=${error.stderr.slice(0, 300)}`;
|
||||
}
|
||||
return `exit ${error.exitCode} stderr=${error.stderr.slice(0, 500)}`;
|
||||
}
|
||||
function lastMetaForRole2(messages, role) {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === role) {
|
||||
return messages[i].meta;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function buildTesterRole({ nerveRoot }) {
|
||||
return async (start, messages) => {
|
||||
const dry = isDryRun3(start);
|
||||
const plannerMeta = lastMetaForRole2(messages, "planner");
|
||||
const coderMeta = lastMetaForRole2(messages, "coder");
|
||||
const attempt = messages.filter((m) => m.role === "tester").length + 1;
|
||||
if (plannerMeta === null || coderMeta === null) {
|
||||
return {
|
||||
content: "tester cannot continue: missing planner/coder output",
|
||||
meta: {
|
||||
workflowName: "",
|
||||
attempt,
|
||||
passed: false,
|
||||
dryRunLog: "",
|
||||
reason: "missing planner/coder output"
|
||||
}
|
||||
};
|
||||
}
|
||||
if (!coderMeta.lintPassed || !coderMeta.buildPassed) {
|
||||
return {
|
||||
content: "tester blocked: coder has not passed lint+build",
|
||||
meta: {
|
||||
workflowName: coderMeta.workflowName,
|
||||
attempt,
|
||||
passed: false,
|
||||
dryRunLog: `${coderMeta.lintLog}
|
||||
|
||||
${coderMeta.buildLog}`,
|
||||
reason: "coder did not pass lint+build"
|
||||
}
|
||||
};
|
||||
}
|
||||
if (dry) {
|
||||
return {
|
||||
content: "PASS \u2014 dry-run mode",
|
||||
meta: {
|
||||
workflowName: coderMeta.workflowName,
|
||||
attempt,
|
||||
passed: true,
|
||||
dryRunLog: "[dry-run] tester skipped external checks",
|
||||
reason: "dry-run mode"
|
||||
}
|
||||
};
|
||||
}
|
||||
const prompt = testerPrompt({
|
||||
workflowName: coderMeta.workflowName,
|
||||
plannerSpec: {
|
||||
roles: plannerMeta.roles,
|
||||
flowTransitions: plannerMeta.flowTransitions,
|
||||
validationLoopsDesign: plannerMeta.validationLoopsDesign,
|
||||
externalDeps: plannerMeta.externalDeps,
|
||||
dataFlow: plannerMeta.dataFlow
|
||||
},
|
||||
coderOutput: coderMeta.cursorOutput,
|
||||
nerveRoot
|
||||
});
|
||||
const run = await cursorAgent2({
|
||||
prompt,
|
||||
mode: "ask",
|
||||
cwd: nerveRoot,
|
||||
env: null,
|
||||
timeoutMs: null,
|
||||
dryRun: false
|
||||
});
|
||||
if (!run.ok) {
|
||||
return {
|
||||
content: "tester agent failed",
|
||||
meta: {
|
||||
workflowName: coderMeta.workflowName,
|
||||
attempt,
|
||||
passed: false,
|
||||
dryRunLog: "",
|
||||
reason: `tester agent failed: ${formatSpawnFailure2(run.error)}`
|
||||
}
|
||||
};
|
||||
}
|
||||
const text = run.value.trim();
|
||||
const pass = text.startsWith("PASS|");
|
||||
const fail = text.startsWith("FAIL|");
|
||||
if (!pass && !fail) {
|
||||
return {
|
||||
content: "tester format invalid",
|
||||
meta: {
|
||||
workflowName: coderMeta.workflowName,
|
||||
attempt,
|
||||
passed: false,
|
||||
dryRunLog: text,
|
||||
reason: "tester format invalid"
|
||||
}
|
||||
};
|
||||
}
|
||||
const parts = text.split("|");
|
||||
const reason = parts[1] ?? "no reason";
|
||||
const log = parts.slice(2).join("|").trim();
|
||||
return {
|
||||
content: `${pass ? "PASS" : "FAIL"} \u2014 ${reason}`,
|
||||
meta: {
|
||||
workflowName: coderMeta.workflowName,
|
||||
attempt,
|
||||
passed: pass,
|
||||
dryRunLog: log,
|
||||
reason
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// roles/committer/index.ts
|
||||
import { cursorAgent as cursorAgent3, isDryRun as isDryRun4, spawnSafe as spawnSafe2 } from "@uncaged/nerve-workflow-utils";
|
||||
import { z as z4 } from "zod";
|
||||
|
||||
// roles/committer/prompt.ts
|
||||
function committerPrompt({
|
||||
nerveRoot,
|
||||
workflowName,
|
||||
userPrompt,
|
||||
testerReason
|
||||
}) {
|
||||
return `You are a git committer subagent for Nerve workflow generation.
|
||||
Repository root: ${nerveRoot}
|
||||
|
||||
Goal:
|
||||
- Commit and push generated workflow "${workflowName}".
|
||||
- Handle dirty worktree safely (do not discard unrelated user edits).
|
||||
- Detect default branch automatically.
|
||||
- Create a focused branch for this workflow update.
|
||||
- Stage only workflow files and required config updates.
|
||||
|
||||
Context:
|
||||
- User prompt summary: ${userPrompt.slice(0, 500)}
|
||||
- Tester result: ${testerReason}
|
||||
|
||||
Expected output format:
|
||||
BRANCH=<branch-or-empty>
|
||||
COMMIT=<hash-or-empty>
|
||||
PUSHED=<true|false|unknown>
|
||||
LOG_START
|
||||
<details>
|
||||
LOG_END`;
|
||||
}
|
||||
|
||||
// roles/committer/index.ts
|
||||
var committerMetaSchema = z4.object({
|
||||
invoked: z4.boolean().default(false),
|
||||
success: z4.boolean().default(false),
|
||||
branch: z4.string().nullable().default(null),
|
||||
commitHash: z4.string().nullable().default(null),
|
||||
pushed: z4.boolean().nullable().default(null),
|
||||
log: z4.string().default(""),
|
||||
error: z4.string().nullable().default(null)
|
||||
});
|
||||
function formatSpawnFailure3(error) {
|
||||
if (error.kind === "spawn_failed") {
|
||||
return error.message;
|
||||
}
|
||||
if (error.kind === "timeout") {
|
||||
return `timeout stdout=${error.stdout.slice(0, 300)} stderr=${error.stderr.slice(0, 300)}`;
|
||||
}
|
||||
return `exit ${error.exitCode} stderr=${error.stderr.slice(0, 500)}`;
|
||||
}
|
||||
function lastMetaForRole3(messages, role) {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === role) {
|
||||
return messages[i].meta;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function inferWorkflowName(messages) {
|
||||
const tester = lastMetaForRole3(messages, "tester");
|
||||
if (tester !== null && tester.workflowName.trim().length > 0) {
|
||||
return tester.workflowName.trim();
|
||||
}
|
||||
const coder = lastMetaForRole3(messages, "coder");
|
||||
if (coder !== null && coder.workflowName.trim().length > 0) {
|
||||
return coder.workflowName.trim();
|
||||
}
|
||||
const planner = lastMetaForRole3(messages, "planner");
|
||||
if (planner !== null && planner.workflowName.trim().length > 0) {
|
||||
return planner.workflowName.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
async function runHermesCommitter(task, nerveRoot) {
|
||||
const commandAttempts = [
|
||||
{ cmd: "hermes-agent", args: ["--cwd", nerveRoot, "--task", task] },
|
||||
{ cmd: "hermes", args: ["agent", "--cwd", nerveRoot, "--task", task] }
|
||||
];
|
||||
for (const candidate of commandAttempts) {
|
||||
const run = await spawnSafe2(candidate.cmd, candidate.args, {
|
||||
cwd: nerveRoot,
|
||||
env: null,
|
||||
timeoutMs: 6e5,
|
||||
dryRun: false
|
||||
});
|
||||
if (!run.ok) {
|
||||
continue;
|
||||
}
|
||||
const text = `${run.value.stdout}
|
||||
${run.value.stderr}`;
|
||||
const branch2 = text.match(/^BRANCH=(.*)$/m)?.[1]?.trim() ?? null;
|
||||
const commitHash2 = text.match(/^COMMIT=(.*)$/m)?.[1]?.trim() ?? null;
|
||||
const pushedText = text.match(/^PUSHED=(.*)$/m)?.[1]?.trim().toLowerCase() ?? "unknown";
|
||||
const pushed = pushedText === "true" ? true : pushedText === "false" ? false : null;
|
||||
return {
|
||||
invoked: true,
|
||||
success: true,
|
||||
branch: branch2 && branch2.length > 0 ? branch2 : null,
|
||||
commitHash: commitHash2 && commitHash2.length > 0 ? commitHash2 : null,
|
||||
pushed,
|
||||
log: text.slice(0, 2e4),
|
||||
error: null
|
||||
};
|
||||
}
|
||||
const fallback = await cursorAgent3({
|
||||
prompt: `Run this git committer task in repository ${nerveRoot}:
|
||||
|
||||
${task}`,
|
||||
mode: "default",
|
||||
cwd: nerveRoot,
|
||||
env: null,
|
||||
timeoutMs: null,
|
||||
dryRun: false
|
||||
});
|
||||
if (!fallback.ok) {
|
||||
return {
|
||||
invoked: true,
|
||||
success: false,
|
||||
branch: null,
|
||||
commitHash: null,
|
||||
pushed: null,
|
||||
log: "",
|
||||
error: `hermes and fallback both failed: ${formatSpawnFailure3(fallback.error)}`
|
||||
};
|
||||
}
|
||||
const out = fallback.value;
|
||||
const branch = out.match(/(?:branch|BRANCH)\s*[:=]\s*([^\s]+)/)?.[1] ?? null;
|
||||
const commitHash = out.match(/[a-f0-9]{7,40}/)?.[0] ?? null;
|
||||
return {
|
||||
invoked: true,
|
||||
success: true,
|
||||
branch,
|
||||
commitHash,
|
||||
pushed: out.toLowerCase().includes("push") ? true : null,
|
||||
log: out.slice(0, 2e4),
|
||||
error: null
|
||||
};
|
||||
}
|
||||
function buildCommitterRole({ nerveRoot }) {
|
||||
return async (start, messages) => {
|
||||
const dry = isDryRun4(start);
|
||||
const planner = lastMetaForRole3(messages, "planner");
|
||||
const tester = lastMetaForRole3(messages, "tester");
|
||||
const workflowName = inferWorkflowName(messages);
|
||||
const skipMeta = {
|
||||
invoked: false,
|
||||
success: false,
|
||||
branch: null,
|
||||
commitHash: null,
|
||||
pushed: null,
|
||||
log: "",
|
||||
error: null
|
||||
};
|
||||
if (planner === null || tester === null || workflowName.length === 0) {
|
||||
return {
|
||||
content: "committer skipped: missing planner/tester/workflowName context",
|
||||
meta: { ...skipMeta, error: "missing committer context" }
|
||||
};
|
||||
}
|
||||
if (!tester.passed) {
|
||||
return {
|
||||
content: "committer skipped: tester not passed",
|
||||
meta: { ...skipMeta, error: "tester not passed" }
|
||||
};
|
||||
}
|
||||
if (dry) {
|
||||
return {
|
||||
content: "[dry-run] skipped hermes committer",
|
||||
meta: {
|
||||
invoked: true,
|
||||
success: true,
|
||||
branch: "wf/dry-run",
|
||||
commitHash: null,
|
||||
pushed: null,
|
||||
log: "[dry-run] skipped hermes committer",
|
||||
error: null
|
||||
}
|
||||
};
|
||||
}
|
||||
const task = committerPrompt({
|
||||
nerveRoot,
|
||||
workflowName,
|
||||
userPrompt: planner.userPrompt,
|
||||
testerReason: tester.reason
|
||||
});
|
||||
const committed = await runHermesCommitter(task, nerveRoot);
|
||||
return {
|
||||
content: committed.success ? committed.log : `committer failed: ${committed.error ?? "unknown"}`,
|
||||
meta: committed
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// moderator.ts
|
||||
import { END } from "@uncaged/nerve-core";
|
||||
var moderator = (context) => {
|
||||
if (context.steps.length === 0) {
|
||||
return "planner";
|
||||
}
|
||||
const last = context.steps[context.steps.length - 1];
|
||||
if (last.role === "planner") {
|
||||
if (last.meta.workflowName.trim().length > 0) return "coder";
|
||||
const plannerAttempts = context.steps.filter((s) => s.role === "planner").length;
|
||||
return plannerAttempts < 3 ? "planner" : END;
|
||||
}
|
||||
if (last.role === "coder") {
|
||||
if (last.meta.lintPassed && last.meta.buildPassed) {
|
||||
return "tester";
|
||||
}
|
||||
if (last.meta.attempt < 3) {
|
||||
return "coder";
|
||||
}
|
||||
return END;
|
||||
}
|
||||
if (last.role === "tester") {
|
||||
if (last.meta.passed) {
|
||||
return "committer";
|
||||
}
|
||||
if (last.meta.attempt < 3) {
|
||||
return "coder";
|
||||
}
|
||||
return END;
|
||||
}
|
||||
return END;
|
||||
};
|
||||
|
||||
// build.ts
|
||||
function buildWorkflowGenerator({
|
||||
provider,
|
||||
nerveRoot
|
||||
}) {
|
||||
const workflowsDir = join3(nerveRoot, "workflows");
|
||||
return {
|
||||
name: "workflow-generator",
|
||||
roles: {
|
||||
planner: buildPlannerRole({ provider, nerveRoot, workflowsDir }),
|
||||
coder: buildCoderRole({ nerveRoot, workflowsDir }),
|
||||
tester: buildTesterRole({ nerveRoot }),
|
||||
committer: buildCommitterRole({ nerveRoot })
|
||||
},
|
||||
moderator
|
||||
};
|
||||
}
|
||||
|
||||
// index.ts
|
||||
var HOME = process.env.HOME ?? "/home/azureuser";
|
||||
var NERVE_ROOT = join4(HOME, ".uncaged-nerve");
|
||||
var apiKey = process.env.DASHSCOPE_API_KEY;
|
||||
var baseUrl = process.env.DASHSCOPE_BASE_URL;
|
||||
var model = process.env.DASHSCOPE_MODEL ?? "qwen-plus";
|
||||
if (!apiKey || !baseUrl) {
|
||||
throw new Error("Set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL");
|
||||
}
|
||||
var workflow = buildWorkflowGenerator({
|
||||
provider: { apiKey, baseUrl, model },
|
||||
nerveRoot: NERVE_ROOT
|
||||
});
|
||||
var index_default = workflow;
|
||||
export {
|
||||
index_default as default
|
||||
};
|
||||
@ -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<string | null> {
|
||||
const result = await spawnSafe("cfg", ["get", key], {
|
||||
cwd: NERVE_ROOT,
|
||||
env: null,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
if (!result.ok) {
|
||||
return null;
|
||||
}
|
||||
const v = result.value.stdout.trim();
|
||||
return v.length > 0 ? v : null;
|
||||
}
|
||||
|
||||
async function resolveDashScopeProvider(): Promise<{
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
} | null> {
|
||||
const apiKey = process.env.DASHSCOPE_API_KEY ?? (await cfgGet("DASHSCOPE_API_KEY"));
|
||||
const baseUrl = process.env.DASHSCOPE_BASE_URL ?? (await cfgGet("DASHSCOPE_BASE_URL"));
|
||||
const model = process.env.DASHSCOPE_MODEL ?? (await cfgGet("DASHSCOPE_MODEL")) ?? "qwen-plus";
|
||||
if (!apiKey || !baseUrl) {
|
||||
return null;
|
||||
}
|
||||
return { apiKey, baseUrl, model };
|
||||
}
|
||||
|
||||
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 scanGeneratedCodePitfalls(source: string): string[] {
|
||||
const issues: string[] = [];
|
||||
if (/\bawait\s+import\s*\(/.test(source)) {
|
||||
issues.push("Found await import() in generated workflow code");
|
||||
}
|
||||
if (/\bimport\s*\(\s*["'`]/.test(source) && !source.includes("Dynamic import required")) {
|
||||
issues.push("Found undocumented dynamic import() call");
|
||||
}
|
||||
if (!/\bexport\s+default\s+/.test(source)) {
|
||||
issues.push("Missing default export of WorkflowDefinition");
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
function inferWorkflowName(messages: WorkflowMessage[]): string {
|
||||
const tester = lastMetaForRole<WorkflowMeta["tester"]>(messages, "tester");
|
||||
if (tester !== null && tester.workflowName.trim().length > 0) {
|
||||
return tester.workflowName.trim();
|
||||
}
|
||||
const coder = lastMetaForRole<WorkflowMeta["coder"]>(messages, "coder");
|
||||
if (coder !== null && coder.workflowName.trim().length > 0) {
|
||||
return coder.workflowName.trim();
|
||||
}
|
||||
const planner = lastMetaForRole<WorkflowMeta["planner"]>(messages, "planner");
|
||||
if (planner !== null && planner.workflowName.trim().length > 0) {
|
||||
return planner.workflowName.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function runLintAndBuild(
|
||||
workflowDir: string,
|
||||
dry: boolean,
|
||||
): Promise<{
|
||||
lintPassed: boolean;
|
||||
buildPassed: boolean;
|
||||
lintLog: string;
|
||||
buildLog: string;
|
||||
reason: string | null;
|
||||
}> {
|
||||
const lintRun = await spawnSafe("pnpm", ["run", "check"], {
|
||||
cwd: workflowDir,
|
||||
env: null,
|
||||
timeoutMs: 300_000,
|
||||
dryRun: dry,
|
||||
});
|
||||
if (!lintRun.ok) {
|
||||
return {
|
||||
lintPassed: false,
|
||||
buildPassed: false,
|
||||
lintLog: formatSpawnFailure(lintRun.error),
|
||||
buildLog: "",
|
||||
reason: `lint failed: ${formatSpawnFailure(lintRun.error)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const lintLog = lintRun.value.stderr.trim() || lintRun.value.stdout.trim() || "(no output)";
|
||||
const tscRun = await spawnSafe("npx", ["tsc", "--noEmit"], {
|
||||
cwd: workflowDir,
|
||||
env: null,
|
||||
timeoutMs: 300_000,
|
||||
dryRun: dry,
|
||||
});
|
||||
if (!tscRun.ok) {
|
||||
return {
|
||||
lintPassed: true,
|
||||
buildPassed: false,
|
||||
lintLog,
|
||||
buildLog: formatSpawnFailure(tscRun.error),
|
||||
reason: `build failed: ${formatSpawnFailure(tscRun.error)}`,
|
||||
};
|
||||
}
|
||||
const buildLog = tscRun.value.stderr.trim() || tscRun.value.stdout.trim() || "(no output)";
|
||||
return { lintPassed: true, buildPassed: true, lintLog, buildLog, reason: null };
|
||||
}
|
||||
|
||||
async function runTesterDryRun(
|
||||
workflowName: string,
|
||||
planner: WorkflowMeta["planner"],
|
||||
coder: WorkflowMeta["coder"],
|
||||
dry: boolean,
|
||||
): Promise<{ passed: boolean; reason: string; log: string }> {
|
||||
if (dry) {
|
||||
return {
|
||||
passed: true,
|
||||
reason: "dry-run mode",
|
||||
log: "[dry-run] tester skipped external checks",
|
||||
};
|
||||
}
|
||||
const prompt = `You are testing a generated Nerve workflow by doing a dry-run review.
|
||||
|
||||
Workflow: ${workflowName}
|
||||
|
||||
Planner specification:
|
||||
${JSON.stringify(
|
||||
{
|
||||
roles: planner.roles,
|
||||
flowTransitions: planner.flowTransitions,
|
||||
validationLoopsDesign: planner.validationLoopsDesign,
|
||||
externalDeps: planner.externalDeps,
|
||||
dataFlow: planner.dataFlow,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
|
||||
Coder output summary:
|
||||
${coder.cursorOutput.slice(0, 6000)}
|
||||
|
||||
Required checks:
|
||||
1) Verify role transitions are coherent and terminates to END.
|
||||
2) Verify generated workflow adheres to planner intent.
|
||||
3) Verify retry loops are explicit for recoverable failures.
|
||||
4) Verify no obvious runtime-breaking issue in generated index.ts.
|
||||
|
||||
Return exactly:
|
||||
PASS|<reason>|<compact markdown log>
|
||||
or
|
||||
FAIL|<reason>|<compact markdown log>`;
|
||||
|
||||
const run = await cursorAgent({
|
||||
prompt,
|
||||
mode: "ask",
|
||||
cwd: NERVE_ROOT,
|
||||
env: null,
|
||||
timeoutMs: null,
|
||||
dryRun: false,
|
||||
});
|
||||
if (!run.ok) {
|
||||
return {
|
||||
passed: false,
|
||||
reason: `tester agent failed: ${formatSpawnFailure(run.error)}`,
|
||||
log: "",
|
||||
};
|
||||
}
|
||||
const text = run.value.trim();
|
||||
const pass = text.startsWith("PASS|");
|
||||
const fail = text.startsWith("FAIL|");
|
||||
if (!pass && !fail) {
|
||||
return { passed: false, reason: "tester format invalid", log: text };
|
||||
}
|
||||
const parts = text.split("|");
|
||||
const reason = parts[1] ?? "no reason";
|
||||
const log = parts.slice(2).join("|").trim();
|
||||
return { passed: pass, reason, log };
|
||||
}
|
||||
|
||||
async function runHermesCommitter(
|
||||
workflowName: string,
|
||||
userPrompt: string,
|
||||
testerReason: string,
|
||||
dry: boolean,
|
||||
): Promise<{
|
||||
invoked: boolean;
|
||||
success: boolean;
|
||||
branch: string | null;
|
||||
commitHash: string | null;
|
||||
pushed: boolean | null;
|
||||
log: string;
|
||||
error: string | null;
|
||||
}> {
|
||||
const task = `You are a git committer subagent for Nerve workflow generation.
|
||||
Repository root: ${NERVE_ROOT}
|
||||
|
||||
Goal:
|
||||
- Commit and push generated workflow "${workflowName}".
|
||||
- Handle dirty worktree safely (do not discard unrelated user edits).
|
||||
- Detect default branch automatically.
|
||||
- Create a focused branch for this workflow update.
|
||||
- Stage only workflow files and required config updates.
|
||||
|
||||
Context:
|
||||
- User prompt summary: ${userPrompt.slice(0, 500)}
|
||||
- Tester result: ${testerReason}
|
||||
|
||||
Expected output format:
|
||||
BRANCH=<branch-or-empty>
|
||||
COMMIT=<hash-or-empty>
|
||||
PUSHED=<true|false|unknown>
|
||||
LOG_START
|
||||
<details>
|
||||
LOG_END`;
|
||||
|
||||
if (dry) {
|
||||
return {
|
||||
invoked: true,
|
||||
success: true,
|
||||
branch: "wf/dry-run",
|
||||
commitHash: null,
|
||||
pushed: null,
|
||||
log: "[dry-run] skipped hermes committer",
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
const commandAttempts: Array<{ cmd: string; args: string[] }> = [
|
||||
{ cmd: "hermes-agent", args: ["--cwd", NERVE_ROOT, "--task", task] },
|
||||
{ cmd: "hermes", args: ["agent", "--cwd", NERVE_ROOT, "--task", task] },
|
||||
];
|
||||
|
||||
for (const candidate of commandAttempts) {
|
||||
const run = await spawnSafe(candidate.cmd, candidate.args, {
|
||||
cwd: NERVE_ROOT,
|
||||
env: null,
|
||||
timeoutMs: 600_000,
|
||||
dryRun: false,
|
||||
});
|
||||
if (!run.ok) {
|
||||
continue;
|
||||
}
|
||||
const text = `${run.value.stdout}\n${run.value.stderr}`;
|
||||
const branch = text.match(/^BRANCH=(.*)$/m)?.[1]?.trim() ?? null;
|
||||
const commitHash = text.match(/^COMMIT=(.*)$/m)?.[1]?.trim() ?? null;
|
||||
const pushedText = text.match(/^PUSHED=(.*)$/m)?.[1]?.trim().toLowerCase() ?? "unknown";
|
||||
const pushed = pushedText === "true" ? true : pushedText === "false" ? false : null;
|
||||
return {
|
||||
invoked: true,
|
||||
success: true,
|
||||
branch: branch && branch.length > 0 ? branch : null,
|
||||
commitHash: commitHash && commitHash.length > 0 ? commitHash : null,
|
||||
pushed,
|
||||
log: text.slice(0, 20_000),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
const fallback = await cursorAgent({
|
||||
prompt: `Run this git committer task in repository ${NERVE_ROOT}:\n\n${task}`,
|
||||
mode: "default",
|
||||
cwd: NERVE_ROOT,
|
||||
env: null,
|
||||
timeoutMs: null,
|
||||
dryRun: false,
|
||||
});
|
||||
if (!fallback.ok) {
|
||||
return {
|
||||
invoked: true,
|
||||
success: false,
|
||||
branch: null,
|
||||
commitHash: null,
|
||||
pushed: null,
|
||||
log: "",
|
||||
error: `hermes and fallback both failed: ${formatSpawnFailure(fallback.error)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const out = fallback.value;
|
||||
const branch = out.match(/(?:branch|BRANCH)\s*[:=]\s*([^\s]+)/)?.[1] ?? null;
|
||||
const commitHash = out.match(/[a-f0-9]{7,40}/)?.[0] ?? null;
|
||||
return {
|
||||
invoked: true,
|
||||
success: true,
|
||||
branch,
|
||||
commitHash,
|
||||
pushed: out.toLowerCase().includes("push") ? true : null,
|
||||
log: out.slice(0, 20_000),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
const workflow: WorkflowDefinition<WorkflowMeta> = {
|
||||
name: "workflow-generator",
|
||||
|
||||
roles: {
|
||||
async planner(
|
||||
start: StartStep,
|
||||
_messages: WorkflowMessage[],
|
||||
): Promise<RoleResult<WorkflowMeta["planner"]>> {
|
||||
const dry = isDryRun(start);
|
||||
const provider = await resolveDashScopeProvider();
|
||||
const userPrompt = start.content;
|
||||
|
||||
if (provider === null) {
|
||||
return {
|
||||
content: "Cannot run planner: missing DASHSCOPE_API_KEY or DASHSCOPE_BASE_URL.",
|
||||
meta: {
|
||||
userPrompt,
|
||||
workflowName: "",
|
||||
roles: [],
|
||||
flowTransitions: "",
|
||||
validationLoopsDesign: "",
|
||||
externalDeps: "",
|
||||
dataFlow: "",
|
||||
planMarkdown: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const planningText = `Design a Nerve workflow plan from this request.
|
||||
|
||||
${nerveAgentContext}
|
||||
|
||||
User request:
|
||||
${userPrompt}
|
||||
|
||||
Target root: ${NERVE_ROOT}
|
||||
Workflow dir root: ${WORKFLOWS_DIR}
|
||||
|
||||
Reference structure:
|
||||
\`\`\`ts
|
||||
${buildSenseGeneratorReference().slice(0, 18_000)}
|
||||
\`\`\`
|
||||
|
||||
Current nerve.yaml:
|
||||
\`\`\`yaml
|
||||
${getNerveYaml()}
|
||||
\`\`\`
|
||||
|
||||
Produce a complete markdown plan that includes:
|
||||
- workflow name
|
||||
- roles list
|
||||
- flow/transitions
|
||||
- validation loops design
|
||||
- external deps
|
||||
- data flow`;
|
||||
|
||||
const extracted = await llmExtract({
|
||||
text: planningText,
|
||||
schema: plannerExtractSchema,
|
||||
provider,
|
||||
dryRun: dry,
|
||||
});
|
||||
if (!extracted.ok) {
|
||||
return {
|
||||
content: `[planner] llmExtract failed: ${JSON.stringify(extracted.error)}`,
|
||||
meta: {
|
||||
userPrompt,
|
||||
workflowName: "",
|
||||
roles: [],
|
||||
flowTransitions: "",
|
||||
validationLoopsDesign: "",
|
||||
externalDeps: "",
|
||||
dataFlow: "",
|
||||
planMarkdown: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const value = extracted.value;
|
||||
const planMarkdown =
|
||||
value.planMarkdown.length > 0
|
||||
? value.planMarkdown
|
||||
: [
|
||||
`# Workflow Plan`,
|
||||
`- workflowName: ${value.workflowName}`,
|
||||
``,
|
||||
`## Roles`,
|
||||
...value.roles.map((r) => `- ${r.name}: ${r.goal} (${r.io})`),
|
||||
``,
|
||||
`## Flow Transitions`,
|
||||
value.flowTransitions,
|
||||
``,
|
||||
`## Validation Loops`,
|
||||
value.validationLoopsDesign,
|
||||
``,
|
||||
`## External Dependencies`,
|
||||
value.externalDeps,
|
||||
``,
|
||||
`## Data Flow`,
|
||||
value.dataFlow,
|
||||
].join("\n");
|
||||
|
||||
return {
|
||||
content: planMarkdown,
|
||||
meta: {
|
||||
userPrompt,
|
||||
workflowName: value.workflowName,
|
||||
roles: value.roles,
|
||||
flowTransitions: value.flowTransitions,
|
||||
validationLoopsDesign: value.validationLoopsDesign,
|
||||
externalDeps: value.externalDeps,
|
||||
dataFlow: value.dataFlow,
|
||||
planMarkdown,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async coder(start: StartStep, messages: WorkflowMessage[]): Promise<RoleResult<WorkflowMeta["coder"]>> {
|
||||
const dry = isDryRun(start);
|
||||
const plannerMeta = lastMetaForRole<WorkflowMeta["planner"]>(messages, "planner");
|
||||
const previousTester = lastMetaForRole<WorkflowMeta["tester"]>(messages, "tester");
|
||||
const attempt = messages.filter((m) => m.role === "coder").length + 1;
|
||||
|
||||
if (plannerMeta === null || plannerMeta.workflowName.trim().length === 0) {
|
||||
return {
|
||||
content: "coder cannot continue: missing planner output",
|
||||
meta: {
|
||||
workflowName: "",
|
||||
attempt,
|
||||
files: { indexTs: false, packageJson: false, tsconfigJson: false },
|
||||
lintPassed: false,
|
||||
buildPassed: false,
|
||||
lintLog: "",
|
||||
buildLog: "",
|
||||
cursorOutput: "",
|
||||
reason: "missing planner output",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const wfName = plannerMeta.workflowName.trim();
|
||||
const feedback =
|
||||
previousTester !== null && previousTester.passed === false
|
||||
? `\n\nPrevious tester failure to fix:\n${previousTester.reason}\n${previousTester.dryRunLog}\n`
|
||||
: "";
|
||||
|
||||
const codingPrompt = `Implement a Nerve workflow package under ${WORKFLOWS_DIR}/${wfName}/.
|
||||
|
||||
Planner output:
|
||||
${plannerMeta.planMarkdown}
|
||||
|
||||
Structured planner fields:
|
||||
${JSON.stringify(
|
||||
{
|
||||
workflowName: plannerMeta.workflowName,
|
||||
roles: plannerMeta.roles,
|
||||
flowTransitions: plannerMeta.flowTransitions,
|
||||
validationLoopsDesign: plannerMeta.validationLoopsDesign,
|
||||
externalDeps: plannerMeta.externalDeps,
|
||||
dataFlow: plannerMeta.dataFlow,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
${feedback}
|
||||
|
||||
Required files:
|
||||
1) ${WORKFLOWS_DIR}/${wfName}/index.ts
|
||||
2) ${WORKFLOWS_DIR}/${wfName}/package.json
|
||||
3) ${WORKFLOWS_DIR}/${wfName}/tsconfig.json
|
||||
4) update ${NERVE_ROOT}/nerve.yaml with workflows.${wfName}
|
||||
|
||||
Rules:
|
||||
- keep WorkflowDefinition<WorkflowMeta> pattern
|
||||
- no dynamic import()
|
||||
- use types (not interfaces)
|
||||
- include retry-aware moderator routing
|
||||
- write compile-ready TypeScript`;
|
||||
|
||||
const agentRun = await cursorAgent({
|
||||
prompt: codingPrompt,
|
||||
mode: "default",
|
||||
cwd: NERVE_ROOT,
|
||||
env: null,
|
||||
timeoutMs: null,
|
||||
dryRun: dry,
|
||||
});
|
||||
|
||||
const workflowDir = join(WORKFLOWS_DIR, wfName);
|
||||
const files = {
|
||||
indexTs: existsSync(join(workflowDir, "index.ts")),
|
||||
packageJson: existsSync(join(workflowDir, "package.json")),
|
||||
tsconfigJson: existsSync(join(workflowDir, "tsconfig.json")),
|
||||
};
|
||||
const missing = [
|
||||
files.indexTs ? null : "index.ts",
|
||||
files.packageJson ? null : "package.json",
|
||||
files.tsconfigJson ? null : "tsconfig.json",
|
||||
].filter((x) => x !== null) as string[];
|
||||
|
||||
if (!agentRun.ok) {
|
||||
return {
|
||||
content: `coder failed: ${formatSpawnFailure(agentRun.error)}`,
|
||||
meta: {
|
||||
workflowName: wfName,
|
||||
attempt,
|
||||
files,
|
||||
lintPassed: false,
|
||||
buildPassed: false,
|
||||
lintLog: "",
|
||||
buildLog: "",
|
||||
cursorOutput: "",
|
||||
reason: formatSpawnFailure(agentRun.error),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
content: `coder failed: missing required files (${missing.join(", ")})`,
|
||||
meta: {
|
||||
workflowName: wfName,
|
||||
attempt,
|
||||
files,
|
||||
lintPassed: false,
|
||||
buildPassed: false,
|
||||
lintLog: "",
|
||||
buildLog: "",
|
||||
cursorOutput: agentRun.value,
|
||||
reason: `missing files: ${missing.join(", ")}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const source = readFileSync(join(workflowDir, "index.ts"), "utf-8");
|
||||
const pitfalls = scanGeneratedCodePitfalls(source);
|
||||
if (pitfalls.length > 0) {
|
||||
return {
|
||||
content: `coder static check failed:\n${pitfalls.join("\n")}`,
|
||||
meta: {
|
||||
workflowName: wfName,
|
||||
attempt,
|
||||
files,
|
||||
lintPassed: false,
|
||||
buildPassed: false,
|
||||
lintLog: pitfalls.join("\n"),
|
||||
buildLog: "",
|
||||
cursorOutput: agentRun.value,
|
||||
reason: pitfalls.join("; "),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const check = await runLintAndBuild(workflowDir, dry);
|
||||
const passed = check.lintPassed && check.buildPassed;
|
||||
return {
|
||||
content: passed
|
||||
? `coder PASS: lint+build ok\n\n${check.lintLog}\n\n${check.buildLog}`
|
||||
: `coder FAIL: ${check.reason ?? "unknown error"}`,
|
||||
meta: {
|
||||
workflowName: wfName,
|
||||
attempt,
|
||||
files,
|
||||
lintPassed: check.lintPassed,
|
||||
buildPassed: check.buildPassed,
|
||||
lintLog: check.lintLog,
|
||||
buildLog: check.buildLog,
|
||||
cursorOutput: agentRun.value,
|
||||
reason: check.reason,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async tester(start: StartStep, messages: WorkflowMessage[]): Promise<RoleResult<WorkflowMeta["tester"]>> {
|
||||
const dry = isDryRun(start);
|
||||
const plannerMeta = lastMetaForRole<WorkflowMeta["planner"]>(messages, "planner");
|
||||
const coderMeta = lastMetaForRole<WorkflowMeta["coder"]>(messages, "coder");
|
||||
const attempt = messages.filter((m) => m.role === "tester").length + 1;
|
||||
|
||||
if (plannerMeta === null || coderMeta === null) {
|
||||
return {
|
||||
content: "tester cannot continue: missing planner/coder output",
|
||||
meta: {
|
||||
workflowName: "",
|
||||
attempt,
|
||||
passed: false,
|
||||
dryRunLog: "",
|
||||
reason: "missing planner/coder output",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (!coderMeta.lintPassed || !coderMeta.buildPassed) {
|
||||
return {
|
||||
content: "tester blocked: coder has not passed lint+build",
|
||||
meta: {
|
||||
workflowName: coderMeta.workflowName,
|
||||
attempt,
|
||||
passed: false,
|
||||
dryRunLog: `${coderMeta.lintLog}\n\n${coderMeta.buildLog}`,
|
||||
reason: "coder did not pass lint+build",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const dryRun = await runTesterDryRun(coderMeta.workflowName, plannerMeta, coderMeta, dry);
|
||||
return {
|
||||
content: `${dryRun.passed ? "PASS" : "FAIL"} — ${dryRun.reason}`,
|
||||
meta: {
|
||||
workflowName: coderMeta.workflowName,
|
||||
attempt,
|
||||
passed: dryRun.passed,
|
||||
dryRunLog: dryRun.log,
|
||||
reason: dryRun.reason,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async committer(
|
||||
start: StartStep,
|
||||
messages: WorkflowMessage[],
|
||||
): Promise<RoleResult<WorkflowMeta["committer"]>> {
|
||||
const dry = isDryRun(start);
|
||||
const planner = lastMetaForRole<WorkflowMeta["planner"]>(messages, "planner");
|
||||
const tester = lastMetaForRole<WorkflowMeta["tester"]>(messages, "tester");
|
||||
const workflowName = inferWorkflowName(messages);
|
||||
|
||||
if (planner === null || tester === null || workflowName.length === 0) {
|
||||
return {
|
||||
content: "committer skipped: missing planner/tester/workflowName context",
|
||||
meta: {
|
||||
invoked: false,
|
||||
success: false,
|
||||
branch: null,
|
||||
commitHash: null,
|
||||
pushed: null,
|
||||
log: "",
|
||||
error: "missing committer context",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (!tester.passed) {
|
||||
return {
|
||||
content: "committer skipped: tester not passed",
|
||||
meta: {
|
||||
invoked: false,
|
||||
success: false,
|
||||
branch: null,
|
||||
commitHash: null,
|
||||
pushed: null,
|
||||
log: "",
|
||||
error: "tester not passed",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const committed = await runHermesCommitter(
|
||||
workflowName,
|
||||
planner.userPrompt,
|
||||
tester.reason,
|
||||
dry,
|
||||
);
|
||||
return {
|
||||
content: committed.success ? committed.log : `committer failed: ${committed.error ?? "unknown"}`,
|
||||
meta: committed,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
moderator(context) {
|
||||
if (context.steps.length === 0) {
|
||||
return "planner";
|
||||
}
|
||||
const last = context.steps[context.steps.length - 1];
|
||||
|
||||
if (last.role === "planner") {
|
||||
if (last.meta.workflowName.trim().length > 0) return "coder";
|
||||
const plannerAttempts = context.steps.filter((s) => s.role === "planner").length;
|
||||
return plannerAttempts < 3 ? "planner" : END;
|
||||
}
|
||||
if (last.role === "coder") {
|
||||
if (last.meta.lintPassed && last.meta.buildPassed) {
|
||||
return "tester";
|
||||
}
|
||||
if (last.meta.attempt < 3) {
|
||||
return "coder";
|
||||
}
|
||||
return END;
|
||||
}
|
||||
if (last.role === "tester") {
|
||||
if (last.meta.passed) {
|
||||
return "committer";
|
||||
}
|
||||
if (last.meta.attempt < 3) {
|
||||
return "coder";
|
||||
}
|
||||
return END;
|
||||
}
|
||||
return END;
|
||||
},
|
||||
};
|
||||
|
||||
export default workflow;
|
||||
|
||||
45
workflows/workflow-generator/moderator.ts
Normal file
45
workflows/workflow-generator/moderator.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { END } from "@uncaged/nerve-core";
|
||||
import type { Moderator } from "@uncaged/nerve-core";
|
||||
import type { PlannerMeta } from "./roles/planner/index.js";
|
||||
import type { CoderMeta } from "./roles/coder/index.js";
|
||||
import type { TesterMeta } from "./roles/tester/index.js";
|
||||
import type { CommitterMeta } from "./roles/committer/index.js";
|
||||
|
||||
export type WorkflowMeta = {
|
||||
planner: PlannerMeta;
|
||||
coder: CoderMeta;
|
||||
tester: TesterMeta;
|
||||
committer: CommitterMeta;
|
||||
};
|
||||
|
||||
export const moderator: Moderator<WorkflowMeta> = (context) => {
|
||||
if (context.steps.length === 0) {
|
||||
return "planner";
|
||||
}
|
||||
const last = context.steps[context.steps.length - 1];
|
||||
|
||||
if (last.role === "planner") {
|
||||
if (last.meta.workflowName.trim().length > 0) return "coder";
|
||||
const plannerAttempts = context.steps.filter((s) => s.role === "planner").length;
|
||||
return plannerAttempts < 3 ? "planner" : END;
|
||||
}
|
||||
if (last.role === "coder") {
|
||||
if (last.meta.lintPassed && last.meta.buildPassed) {
|
||||
return "tester";
|
||||
}
|
||||
if (last.meta.attempt < 3) {
|
||||
return "coder";
|
||||
}
|
||||
return END;
|
||||
}
|
||||
if (last.role === "tester") {
|
||||
if (last.meta.passed) {
|
||||
return "committer";
|
||||
}
|
||||
if (last.meta.attempt < 3) {
|
||||
return "coder";
|
||||
}
|
||||
return END;
|
||||
}
|
||||
return END;
|
||||
};
|
||||
@ -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": {
|
||||
|
||||
271
workflows/workflow-generator/pnpm-lock.yaml
generated
271
workflows/workflow-generator/pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
254
workflows/workflow-generator/roles/coder/index.ts
Normal file
254
workflows/workflow-generator/roles/coder/index.ts
Normal file
@ -0,0 +1,254 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Role, RoleResult, WorkflowMessage } from "@uncaged/nerve-core";
|
||||
import type { SpawnError } from "@uncaged/nerve-workflow-utils";
|
||||
import { cursorAgent, isDryRun, spawnSafe } from "@uncaged/nerve-workflow-utils";
|
||||
import { z } from "zod";
|
||||
import type { PlannerMeta } from "../planner/index.js";
|
||||
import type { TesterMeta } from "../tester/index.js";
|
||||
import { coderPrompt } from "./prompt.js";
|
||||
|
||||
export const coderMetaSchema = z.object({
|
||||
workflowName: z.string().default(""),
|
||||
attempt: z.number().default(1),
|
||||
files: z
|
||||
.object({
|
||||
indexTs: z.boolean().default(false),
|
||||
packageJson: z.boolean().default(false),
|
||||
tsconfigJson: z.boolean().default(false),
|
||||
})
|
||||
.default({ indexTs: false, packageJson: false, tsconfigJson: false }),
|
||||
lintPassed: z.boolean().default(false),
|
||||
buildPassed: z.boolean().default(false),
|
||||
lintLog: z.string().default(""),
|
||||
buildLog: z.string().default(""),
|
||||
cursorOutput: z.string().default(""),
|
||||
reason: z.string().nullable().default(null),
|
||||
});
|
||||
|
||||
export type CoderMeta = z.infer<typeof coderMetaSchema>;
|
||||
|
||||
export type BuildCoderDeps = {
|
||||
nerveRoot: string;
|
||||
workflowsDir: string;
|
||||
};
|
||||
|
||||
function formatSpawnFailure(error: SpawnError): string {
|
||||
if (error.kind === "spawn_failed") {
|
||||
return error.message;
|
||||
}
|
||||
if (error.kind === "timeout") {
|
||||
return `timeout stdout=${error.stdout.slice(0, 300)} stderr=${error.stderr.slice(0, 300)}`;
|
||||
}
|
||||
return `exit ${error.exitCode} stderr=${error.stderr.slice(0, 500)}`;
|
||||
}
|
||||
|
||||
function scanGeneratedCodePitfalls(source: string): string[] {
|
||||
const issues: string[] = [];
|
||||
if (/\bawait\s+import\s*\(/.test(source)) {
|
||||
issues.push("Found await import() in generated workflow code");
|
||||
}
|
||||
if (/\bimport\s*\(\s*["'`]/.test(source) && !source.includes("Dynamic import required")) {
|
||||
issues.push("Found undocumented dynamic import() call");
|
||||
}
|
||||
if (!/\bexport\s+default\s+/.test(source)) {
|
||||
issues.push("Missing default export of WorkflowDefinition");
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
async function runLintAndBuild(
|
||||
workflowDir: string,
|
||||
dry: boolean,
|
||||
): Promise<{
|
||||
lintPassed: boolean;
|
||||
buildPassed: boolean;
|
||||
lintLog: string;
|
||||
buildLog: string;
|
||||
reason: string | null;
|
||||
}> {
|
||||
const lintRun = await spawnSafe("pnpm", ["run", "check"], {
|
||||
cwd: workflowDir,
|
||||
env: null,
|
||||
timeoutMs: 300_000,
|
||||
dryRun: dry,
|
||||
});
|
||||
if (!lintRun.ok) {
|
||||
return {
|
||||
lintPassed: false,
|
||||
buildPassed: false,
|
||||
lintLog: formatSpawnFailure(lintRun.error),
|
||||
buildLog: "",
|
||||
reason: `lint failed: ${formatSpawnFailure(lintRun.error)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const lintLog = lintRun.value.stderr.trim() || lintRun.value.stdout.trim() || "(no output)";
|
||||
const tscRun = await spawnSafe("npx", ["tsc", "--noEmit"], {
|
||||
cwd: workflowDir,
|
||||
env: null,
|
||||
timeoutMs: 300_000,
|
||||
dryRun: dry,
|
||||
});
|
||||
if (!tscRun.ok) {
|
||||
return {
|
||||
lintPassed: true,
|
||||
buildPassed: false,
|
||||
lintLog,
|
||||
buildLog: formatSpawnFailure(tscRun.error),
|
||||
reason: `build failed: ${formatSpawnFailure(tscRun.error)}`,
|
||||
};
|
||||
}
|
||||
const buildLog = tscRun.value.stderr.trim() || tscRun.value.stdout.trim() || "(no output)";
|
||||
return { lintPassed: true, buildPassed: true, lintLog, buildLog, reason: null };
|
||||
}
|
||||
|
||||
function lastMetaForRole<M>(messages: WorkflowMessage[], role: string): M | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === role) {
|
||||
return messages[i].meta as M;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildCoderRole({ nerveRoot, workflowsDir }: BuildCoderDeps): Role<CoderMeta> {
|
||||
return async (start, messages) => {
|
||||
const dry = isDryRun(start);
|
||||
const plannerMeta = lastMetaForRole<PlannerMeta>(messages, "planner");
|
||||
const previousTester = lastMetaForRole<TesterMeta>(messages, "tester");
|
||||
const attempt = messages.filter((m) => m.role === "coder").length + 1;
|
||||
|
||||
if (plannerMeta === null || plannerMeta.workflowName.trim().length === 0) {
|
||||
return {
|
||||
content: "coder cannot continue: missing planner output",
|
||||
meta: {
|
||||
workflowName: "",
|
||||
attempt,
|
||||
files: { indexTs: false, packageJson: false, tsconfigJson: false },
|
||||
lintPassed: false,
|
||||
buildPassed: false,
|
||||
lintLog: "",
|
||||
buildLog: "",
|
||||
cursorOutput: "",
|
||||
reason: "missing planner output",
|
||||
},
|
||||
} satisfies RoleResult<CoderMeta>;
|
||||
}
|
||||
|
||||
const wfName = plannerMeta.workflowName.trim();
|
||||
const feedback =
|
||||
previousTester !== null && previousTester.passed === false
|
||||
? `\n\nPrevious tester failure to fix:\n${previousTester.reason}\n${previousTester.dryRunLog}\n`
|
||||
: "";
|
||||
|
||||
const prompt = coderPrompt({
|
||||
workflowsDir,
|
||||
wfName,
|
||||
planMarkdown: plannerMeta.planMarkdown,
|
||||
plannerStructured: {
|
||||
workflowName: plannerMeta.workflowName,
|
||||
roles: plannerMeta.roles,
|
||||
flowTransitions: plannerMeta.flowTransitions,
|
||||
validationLoopsDesign: plannerMeta.validationLoopsDesign,
|
||||
externalDeps: plannerMeta.externalDeps,
|
||||
dataFlow: plannerMeta.dataFlow,
|
||||
},
|
||||
feedback,
|
||||
nerveRoot,
|
||||
});
|
||||
|
||||
const agentRun = await cursorAgent({
|
||||
prompt,
|
||||
mode: "default",
|
||||
cwd: nerveRoot,
|
||||
env: null,
|
||||
timeoutMs: null,
|
||||
dryRun: dry,
|
||||
});
|
||||
|
||||
const workflowDir = join(workflowsDir, wfName);
|
||||
const files = {
|
||||
indexTs: existsSync(join(workflowDir, "index.ts")),
|
||||
packageJson: existsSync(join(workflowDir, "package.json")),
|
||||
tsconfigJson: existsSync(join(workflowDir, "tsconfig.json")),
|
||||
};
|
||||
const missing = [
|
||||
files.indexTs ? null : "index.ts",
|
||||
files.packageJson ? null : "package.json",
|
||||
files.tsconfigJson ? null : "tsconfig.json",
|
||||
].filter((x) => x !== null) as string[];
|
||||
|
||||
if (!agentRun.ok) {
|
||||
return {
|
||||
content: `coder failed: ${formatSpawnFailure(agentRun.error)}`,
|
||||
meta: {
|
||||
workflowName: wfName,
|
||||
attempt,
|
||||
files,
|
||||
lintPassed: false,
|
||||
buildPassed: false,
|
||||
lintLog: "",
|
||||
buildLog: "",
|
||||
cursorOutput: "",
|
||||
reason: formatSpawnFailure(agentRun.error),
|
||||
},
|
||||
} satisfies RoleResult<CoderMeta>;
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
content: `coder failed: missing required files (${missing.join(", ")})`,
|
||||
meta: {
|
||||
workflowName: wfName,
|
||||
attempt,
|
||||
files,
|
||||
lintPassed: false,
|
||||
buildPassed: false,
|
||||
lintLog: "",
|
||||
buildLog: "",
|
||||
cursorOutput: agentRun.value,
|
||||
reason: `missing files: ${missing.join(", ")}`,
|
||||
},
|
||||
} satisfies RoleResult<CoderMeta>;
|
||||
}
|
||||
|
||||
const source = readFileSync(join(workflowDir, "index.ts"), "utf-8");
|
||||
const pitfalls = scanGeneratedCodePitfalls(source);
|
||||
if (pitfalls.length > 0) {
|
||||
return {
|
||||
content: `coder static check failed:\n${pitfalls.join("\n")}`,
|
||||
meta: {
|
||||
workflowName: wfName,
|
||||
attempt,
|
||||
files,
|
||||
lintPassed: false,
|
||||
buildPassed: false,
|
||||
lintLog: pitfalls.join("\n"),
|
||||
buildLog: "",
|
||||
cursorOutput: agentRun.value,
|
||||
reason: pitfalls.join("; "),
|
||||
},
|
||||
} satisfies RoleResult<CoderMeta>;
|
||||
}
|
||||
|
||||
const check = await runLintAndBuild(workflowDir, dry);
|
||||
const passed = check.lintPassed && check.buildPassed;
|
||||
return {
|
||||
content: passed
|
||||
? `coder PASS: lint+build ok\n\n${check.lintLog}\n\n${check.buildLog}`
|
||||
: `coder FAIL: ${check.reason ?? "unknown error"}`,
|
||||
meta: {
|
||||
workflowName: wfName,
|
||||
attempt,
|
||||
files,
|
||||
lintPassed: check.lintPassed,
|
||||
buildPassed: check.buildPassed,
|
||||
lintLog: check.lintLog,
|
||||
buildLog: check.buildLog,
|
||||
cursorOutput: agentRun.value,
|
||||
reason: check.reason,
|
||||
},
|
||||
} satisfies RoleResult<CoderMeta>;
|
||||
};
|
||||
}
|
||||
39
workflows/workflow-generator/roles/coder/prompt.ts
Normal file
39
workflows/workflow-generator/roles/coder/prompt.ts
Normal file
@ -0,0 +1,39 @@
|
||||
export type CoderPromptParams = {
|
||||
workflowsDir: string;
|
||||
wfName: string;
|
||||
planMarkdown: string;
|
||||
plannerStructured: object;
|
||||
feedback: string;
|
||||
nerveRoot: string;
|
||||
};
|
||||
|
||||
export function coderPrompt({
|
||||
workflowsDir,
|
||||
wfName,
|
||||
planMarkdown,
|
||||
plannerStructured,
|
||||
feedback,
|
||||
nerveRoot,
|
||||
}: CoderPromptParams): string {
|
||||
return `Implement a Nerve workflow package under ${workflowsDir}/${wfName}/.
|
||||
|
||||
Planner output:
|
||||
${planMarkdown}
|
||||
|
||||
Structured planner fields:
|
||||
${JSON.stringify(plannerStructured, null, 2)}
|
||||
${feedback}
|
||||
|
||||
Required files:
|
||||
1) ${workflowsDir}/${wfName}/index.ts
|
||||
2) ${workflowsDir}/${wfName}/package.json
|
||||
3) ${workflowsDir}/${wfName}/tsconfig.json
|
||||
4) update ${nerveRoot}/nerve.yaml with workflows.${wfName}
|
||||
|
||||
Rules:
|
||||
- keep WorkflowDefinition<WorkflowMeta> pattern
|
||||
- no dynamic import()
|
||||
- use types (not interfaces)
|
||||
- include retry-aware moderator routing
|
||||
- write compile-ready TypeScript`;
|
||||
}
|
||||
190
workflows/workflow-generator/roles/committer/index.ts
Normal file
190
workflows/workflow-generator/roles/committer/index.ts
Normal file
@ -0,0 +1,190 @@
|
||||
import type { Role, RoleResult, WorkflowMessage } from "@uncaged/nerve-core";
|
||||
import type { SpawnError } from "@uncaged/nerve-workflow-utils";
|
||||
import { cursorAgent, isDryRun, spawnSafe } from "@uncaged/nerve-workflow-utils";
|
||||
import { z } from "zod";
|
||||
import type { PlannerMeta } from "../planner/index.js";
|
||||
import type { TesterMeta } from "../tester/index.js";
|
||||
import { committerPrompt } from "./prompt.js";
|
||||
|
||||
export const committerMetaSchema = z.object({
|
||||
invoked: z.boolean().default(false),
|
||||
success: z.boolean().default(false),
|
||||
branch: z.string().nullable().default(null),
|
||||
commitHash: z.string().nullable().default(null),
|
||||
pushed: z.boolean().nullable().default(null),
|
||||
log: z.string().default(""),
|
||||
error: z.string().nullable().default(null),
|
||||
});
|
||||
|
||||
export type CommitterMeta = z.infer<typeof committerMetaSchema>;
|
||||
|
||||
export type BuildCommitterDeps = {
|
||||
nerveRoot: string;
|
||||
};
|
||||
|
||||
function formatSpawnFailure(error: SpawnError): string {
|
||||
if (error.kind === "spawn_failed") {
|
||||
return error.message;
|
||||
}
|
||||
if (error.kind === "timeout") {
|
||||
return `timeout stdout=${error.stdout.slice(0, 300)} stderr=${error.stderr.slice(0, 300)}`;
|
||||
}
|
||||
return `exit ${error.exitCode} stderr=${error.stderr.slice(0, 500)}`;
|
||||
}
|
||||
|
||||
function lastMetaForRole<M>(messages: WorkflowMessage[], role: string): M | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === role) {
|
||||
return messages[i].meta as M;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferWorkflowName(messages: WorkflowMessage[]): string {
|
||||
const tester = lastMetaForRole<TesterMeta>(messages, "tester");
|
||||
if (tester !== null && tester.workflowName.trim().length > 0) {
|
||||
return tester.workflowName.trim();
|
||||
}
|
||||
const coder = lastMetaForRole<{ workflowName: string }>(messages, "coder");
|
||||
if (coder !== null && coder.workflowName.trim().length > 0) {
|
||||
return coder.workflowName.trim();
|
||||
}
|
||||
const planner = lastMetaForRole<PlannerMeta>(messages, "planner");
|
||||
if (planner !== null && planner.workflowName.trim().length > 0) {
|
||||
return planner.workflowName.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function runHermesCommitter(
|
||||
task: string,
|
||||
nerveRoot: string,
|
||||
): Promise<CommitterMeta> {
|
||||
const commandAttempts: Array<{ cmd: string; args: string[] }> = [
|
||||
{ cmd: "hermes-agent", args: ["--cwd", nerveRoot, "--task", task] },
|
||||
{ cmd: "hermes", args: ["agent", "--cwd", nerveRoot, "--task", task] },
|
||||
];
|
||||
|
||||
for (const candidate of commandAttempts) {
|
||||
const run = await spawnSafe(candidate.cmd, candidate.args, {
|
||||
cwd: nerveRoot,
|
||||
env: null,
|
||||
timeoutMs: 600_000,
|
||||
dryRun: false,
|
||||
});
|
||||
if (!run.ok) {
|
||||
continue;
|
||||
}
|
||||
const text = `${run.value.stdout}\n${run.value.stderr}`;
|
||||
const branch = text.match(/^BRANCH=(.*)$/m)?.[1]?.trim() ?? null;
|
||||
const commitHash = text.match(/^COMMIT=(.*)$/m)?.[1]?.trim() ?? null;
|
||||
const pushedText = text.match(/^PUSHED=(.*)$/m)?.[1]?.trim().toLowerCase() ?? "unknown";
|
||||
const pushed = pushedText === "true" ? true : pushedText === "false" ? false : null;
|
||||
return {
|
||||
invoked: true,
|
||||
success: true,
|
||||
branch: branch && branch.length > 0 ? branch : null,
|
||||
commitHash: commitHash && commitHash.length > 0 ? commitHash : null,
|
||||
pushed,
|
||||
log: text.slice(0, 20_000),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
const fallback = await cursorAgent({
|
||||
prompt: `Run this git committer task in repository ${nerveRoot}:\n\n${task}`,
|
||||
mode: "default",
|
||||
cwd: nerveRoot,
|
||||
env: null,
|
||||
timeoutMs: null,
|
||||
dryRun: false,
|
||||
});
|
||||
if (!fallback.ok) {
|
||||
return {
|
||||
invoked: true,
|
||||
success: false,
|
||||
branch: null,
|
||||
commitHash: null,
|
||||
pushed: null,
|
||||
log: "",
|
||||
error: `hermes and fallback both failed: ${formatSpawnFailure(fallback.error)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const out = fallback.value;
|
||||
const branch = out.match(/(?:branch|BRANCH)\s*[:=]\s*([^\s]+)/)?.[1] ?? null;
|
||||
const commitHash = out.match(/[a-f0-9]{7,40}/)?.[0] ?? null;
|
||||
return {
|
||||
invoked: true,
|
||||
success: true,
|
||||
branch,
|
||||
commitHash,
|
||||
pushed: out.toLowerCase().includes("push") ? true : null,
|
||||
log: out.slice(0, 20_000),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCommitterRole({ nerveRoot }: BuildCommitterDeps): Role<CommitterMeta> {
|
||||
return async (start, messages) => {
|
||||
const dry = isDryRun(start);
|
||||
const planner = lastMetaForRole<PlannerMeta>(messages, "planner");
|
||||
const tester = lastMetaForRole<TesterMeta>(messages, "tester");
|
||||
const workflowName = inferWorkflowName(messages);
|
||||
|
||||
const skipMeta: CommitterMeta = {
|
||||
invoked: false,
|
||||
success: false,
|
||||
branch: null,
|
||||
commitHash: null,
|
||||
pushed: null,
|
||||
log: "",
|
||||
error: null,
|
||||
};
|
||||
|
||||
if (planner === null || tester === null || workflowName.length === 0) {
|
||||
return {
|
||||
content: "committer skipped: missing planner/tester/workflowName context",
|
||||
meta: { ...skipMeta, error: "missing committer context" },
|
||||
} satisfies RoleResult<CommitterMeta>;
|
||||
}
|
||||
|
||||
if (!tester.passed) {
|
||||
return {
|
||||
content: "committer skipped: tester not passed",
|
||||
meta: { ...skipMeta, error: "tester not passed" },
|
||||
} satisfies RoleResult<CommitterMeta>;
|
||||
}
|
||||
|
||||
if (dry) {
|
||||
return {
|
||||
content: "[dry-run] skipped hermes committer",
|
||||
meta: {
|
||||
invoked: true,
|
||||
success: true,
|
||||
branch: "wf/dry-run",
|
||||
commitHash: null,
|
||||
pushed: null,
|
||||
log: "[dry-run] skipped hermes committer",
|
||||
error: null,
|
||||
},
|
||||
} satisfies RoleResult<CommitterMeta>;
|
||||
}
|
||||
|
||||
const task = committerPrompt({
|
||||
nerveRoot,
|
||||
workflowName,
|
||||
userPrompt: planner.userPrompt,
|
||||
testerReason: tester.reason,
|
||||
});
|
||||
|
||||
const committed = await runHermesCommitter(task, nerveRoot);
|
||||
return {
|
||||
content: committed.success
|
||||
? committed.log
|
||||
: `committer failed: ${committed.error ?? "unknown"}`,
|
||||
meta: committed,
|
||||
} satisfies RoleResult<CommitterMeta>;
|
||||
};
|
||||
}
|
||||
35
workflows/workflow-generator/roles/committer/prompt.ts
Normal file
35
workflows/workflow-generator/roles/committer/prompt.ts
Normal file
@ -0,0 +1,35 @@
|
||||
export type CommitterPromptParams = {
|
||||
nerveRoot: string;
|
||||
workflowName: string;
|
||||
userPrompt: string;
|
||||
testerReason: string;
|
||||
};
|
||||
|
||||
export function committerPrompt({
|
||||
nerveRoot,
|
||||
workflowName,
|
||||
userPrompt,
|
||||
testerReason,
|
||||
}: CommitterPromptParams): string {
|
||||
return `You are a git committer subagent for Nerve workflow generation.
|
||||
Repository root: ${nerveRoot}
|
||||
|
||||
Goal:
|
||||
- Commit and push generated workflow "${workflowName}".
|
||||
- Handle dirty worktree safely (do not discard unrelated user edits).
|
||||
- Detect default branch automatically.
|
||||
- Create a focused branch for this workflow update.
|
||||
- Stage only workflow files and required config updates.
|
||||
|
||||
Context:
|
||||
- User prompt summary: ${userPrompt.slice(0, 500)}
|
||||
- Tester result: ${testerReason}
|
||||
|
||||
Expected output format:
|
||||
BRANCH=<branch-or-empty>
|
||||
COMMIT=<hash-or-empty>
|
||||
PUSHED=<true|false|unknown>
|
||||
LOG_START
|
||||
<details>
|
||||
LOG_END`;
|
||||
}
|
||||
142
workflows/workflow-generator/roles/planner/index.ts
Normal file
142
workflows/workflow-generator/roles/planner/index.ts
Normal file
@ -0,0 +1,142 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Role, RoleResult } from "@uncaged/nerve-core";
|
||||
import type { LlmProvider } from "@uncaged/nerve-workflow-utils";
|
||||
import { isDryRun, llmExtract, nerveAgentContext, readNerveYaml } from "@uncaged/nerve-workflow-utils";
|
||||
import { z } from "zod";
|
||||
import { plannerPrompt } from "./prompt.js";
|
||||
|
||||
const roleSchema = z
|
||||
.object({
|
||||
name: z.string().default(""),
|
||||
goal: z.string().default(""),
|
||||
io: z.string().default(""),
|
||||
})
|
||||
.default({ name: "", goal: "", io: "" });
|
||||
|
||||
export const plannerMetaSchema = z.object({
|
||||
userPrompt: z.string().default(""),
|
||||
workflowName: z
|
||||
.string()
|
||||
.default("")
|
||||
.describe("kebab-case workflow name under workflows/, e.g. issue-fixer"),
|
||||
roles: z.array(roleSchema).default([]),
|
||||
flowTransitions: z.preprocess((v) => (Array.isArray(v) ? v.join("\n") : v), z.string().default("")),
|
||||
validationLoopsDesign: z.preprocess(
|
||||
(v) => (Array.isArray(v) ? v.join("\n") : v),
|
||||
z.string().default(""),
|
||||
),
|
||||
externalDeps: z.preprocess(
|
||||
(v) => (Array.isArray(v) ? v.join(", ") : v),
|
||||
z.string().default(""),
|
||||
),
|
||||
dataFlow: z.preprocess((v) => (Array.isArray(v) ? v.join("\n") : v), z.string().default("")),
|
||||
planMarkdown: z.preprocess(
|
||||
(v) => (Array.isArray(v) ? v.join("\n") : v),
|
||||
z.string().default(""),
|
||||
),
|
||||
});
|
||||
|
||||
export type PlannerMeta = z.infer<typeof plannerMetaSchema>;
|
||||
|
||||
export type BuildPlannerDeps = {
|
||||
provider: LlmProvider;
|
||||
nerveRoot: string;
|
||||
workflowsDir: string;
|
||||
};
|
||||
|
||||
function getNerveYaml(nerveRoot: string): string {
|
||||
const result = readNerveYaml({ nerveRoot });
|
||||
return result.ok ? result.value : "# nerve.yaml unavailable";
|
||||
}
|
||||
|
||||
function getSenseGeneratorReference(workflowsDir: string): string {
|
||||
const p = join(workflowsDir, "sense-generator", "index.ts");
|
||||
if (!existsSync(p)) {
|
||||
return "(missing workflows/sense-generator/index.ts)";
|
||||
}
|
||||
return readFileSync(p, "utf-8");
|
||||
}
|
||||
|
||||
export function buildPlannerRole({
|
||||
provider,
|
||||
nerveRoot,
|
||||
workflowsDir,
|
||||
}: BuildPlannerDeps): Role<PlannerMeta> {
|
||||
return async (start, _messages) => {
|
||||
const dry = isDryRun(start);
|
||||
const userPrompt = start.content;
|
||||
|
||||
const messages = plannerPrompt({
|
||||
nerveAgentContext,
|
||||
userPrompt,
|
||||
nerveRoot,
|
||||
workflowsDir,
|
||||
senseGeneratorReference: getSenseGeneratorReference(workflowsDir),
|
||||
nerveYaml: getNerveYaml(nerveRoot),
|
||||
});
|
||||
|
||||
const extracted = await llmExtract({
|
||||
text: messages.map((m) => m.content).join("\n"),
|
||||
schema: plannerMetaSchema,
|
||||
provider,
|
||||
dryRun: dry,
|
||||
});
|
||||
|
||||
const emptyMeta: PlannerMeta = {
|
||||
userPrompt,
|
||||
workflowName: "",
|
||||
roles: [],
|
||||
flowTransitions: "",
|
||||
validationLoopsDesign: "",
|
||||
externalDeps: "",
|
||||
dataFlow: "",
|
||||
planMarkdown: "",
|
||||
};
|
||||
|
||||
if (!extracted.ok) {
|
||||
return {
|
||||
content: `[planner] llmExtract failed: ${JSON.stringify(extracted.error)}`,
|
||||
meta: emptyMeta,
|
||||
} satisfies RoleResult<PlannerMeta>;
|
||||
}
|
||||
|
||||
const value = extracted.value;
|
||||
const planMarkdown =
|
||||
value.planMarkdown.length > 0
|
||||
? value.planMarkdown
|
||||
: [
|
||||
`# Workflow Plan`,
|
||||
`- workflowName: ${value.workflowName}`,
|
||||
``,
|
||||
`## Roles`,
|
||||
...value.roles.map((r) => `- ${r.name}: ${r.goal} (${r.io})`),
|
||||
``,
|
||||
`## Flow Transitions`,
|
||||
value.flowTransitions,
|
||||
``,
|
||||
`## Validation Loops`,
|
||||
value.validationLoopsDesign,
|
||||
``,
|
||||
`## External Dependencies`,
|
||||
value.externalDeps,
|
||||
``,
|
||||
`## Data Flow`,
|
||||
value.dataFlow,
|
||||
].join("\n");
|
||||
|
||||
return {
|
||||
content: planMarkdown,
|
||||
meta: {
|
||||
userPrompt,
|
||||
workflowName: value.workflowName,
|
||||
roles: value.roles,
|
||||
flowTransitions: value.flowTransitions,
|
||||
validationLoopsDesign: value.validationLoopsDesign,
|
||||
externalDeps: value.externalDeps,
|
||||
dataFlow: value.dataFlow,
|
||||
planMarkdown,
|
||||
},
|
||||
} satisfies RoleResult<PlannerMeta>;
|
||||
};
|
||||
}
|
||||
49
workflows/workflow-generator/roles/planner/prompt.ts
Normal file
49
workflows/workflow-generator/roles/planner/prompt.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import type { LlmMessage } from "@uncaged/nerve-workflow-utils";
|
||||
|
||||
export type PlannerPromptParams = {
|
||||
nerveAgentContext: string;
|
||||
userPrompt: string;
|
||||
nerveRoot: string;
|
||||
workflowsDir: string;
|
||||
senseGeneratorReference: string;
|
||||
nerveYaml: string;
|
||||
};
|
||||
|
||||
export function plannerPrompt({
|
||||
nerveAgentContext,
|
||||
userPrompt,
|
||||
nerveRoot,
|
||||
workflowsDir,
|
||||
senseGeneratorReference,
|
||||
nerveYaml,
|
||||
}: PlannerPromptParams): LlmMessage[] {
|
||||
const content = `Design a Nerve workflow plan from this request.
|
||||
|
||||
${nerveAgentContext}
|
||||
|
||||
User request:
|
||||
${userPrompt}
|
||||
|
||||
Target root: ${nerveRoot}
|
||||
Workflow dir root: ${workflowsDir}
|
||||
|
||||
Reference structure:
|
||||
\`\`\`ts
|
||||
${senseGeneratorReference.slice(0, 18_000)}
|
||||
\`\`\`
|
||||
|
||||
Current nerve.yaml:
|
||||
\`\`\`yaml
|
||||
${nerveYaml}
|
||||
\`\`\`
|
||||
|
||||
Produce a complete markdown plan that includes:
|
||||
- workflow name
|
||||
- roles list
|
||||
- flow/transitions
|
||||
- validation loops design
|
||||
- external deps
|
||||
- data flow`;
|
||||
|
||||
return [{ role: "user", content }];
|
||||
}
|
||||
153
workflows/workflow-generator/roles/tester/index.ts
Normal file
153
workflows/workflow-generator/roles/tester/index.ts
Normal file
@ -0,0 +1,153 @@
|
||||
import type { Role, RoleResult, WorkflowMessage } from "@uncaged/nerve-core";
|
||||
import type { SpawnError } from "@uncaged/nerve-workflow-utils";
|
||||
import { cursorAgent, isDryRun } from "@uncaged/nerve-workflow-utils";
|
||||
import { z } from "zod";
|
||||
import type { CoderMeta } from "../coder/index.js";
|
||||
import type { PlannerMeta } from "../planner/index.js";
|
||||
import { testerPrompt } from "./prompt.js";
|
||||
|
||||
export const testerMetaSchema = z.object({
|
||||
workflowName: z.string().default(""),
|
||||
attempt: z.number().default(1),
|
||||
passed: z.boolean().default(false),
|
||||
dryRunLog: z.string().default(""),
|
||||
reason: z.string().default(""),
|
||||
});
|
||||
|
||||
export type TesterMeta = z.infer<typeof testerMetaSchema>;
|
||||
|
||||
export type BuildTesterDeps = {
|
||||
nerveRoot: string;
|
||||
};
|
||||
|
||||
function formatSpawnFailure(error: SpawnError): string {
|
||||
if (error.kind === "spawn_failed") {
|
||||
return error.message;
|
||||
}
|
||||
if (error.kind === "timeout") {
|
||||
return `timeout stdout=${error.stdout.slice(0, 300)} stderr=${error.stderr.slice(0, 300)}`;
|
||||
}
|
||||
return `exit ${error.exitCode} stderr=${error.stderr.slice(0, 500)}`;
|
||||
}
|
||||
|
||||
function lastMetaForRole<M>(messages: WorkflowMessage[], role: string): M | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === role) {
|
||||
return messages[i].meta as M;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildTesterRole({ nerveRoot }: BuildTesterDeps): Role<TesterMeta> {
|
||||
return async (start, messages) => {
|
||||
const dry = isDryRun(start);
|
||||
const plannerMeta = lastMetaForRole<PlannerMeta>(messages, "planner");
|
||||
const coderMeta = lastMetaForRole<CoderMeta>(messages, "coder");
|
||||
const attempt = messages.filter((m) => m.role === "tester").length + 1;
|
||||
|
||||
if (plannerMeta === null || coderMeta === null) {
|
||||
return {
|
||||
content: "tester cannot continue: missing planner/coder output",
|
||||
meta: {
|
||||
workflowName: "",
|
||||
attempt,
|
||||
passed: false,
|
||||
dryRunLog: "",
|
||||
reason: "missing planner/coder output",
|
||||
},
|
||||
} satisfies RoleResult<TesterMeta>;
|
||||
}
|
||||
|
||||
if (!coderMeta.lintPassed || !coderMeta.buildPassed) {
|
||||
return {
|
||||
content: "tester blocked: coder has not passed lint+build",
|
||||
meta: {
|
||||
workflowName: coderMeta.workflowName,
|
||||
attempt,
|
||||
passed: false,
|
||||
dryRunLog: `${coderMeta.lintLog}\n\n${coderMeta.buildLog}`,
|
||||
reason: "coder did not pass lint+build",
|
||||
},
|
||||
} satisfies RoleResult<TesterMeta>;
|
||||
}
|
||||
|
||||
if (dry) {
|
||||
return {
|
||||
content: "PASS — dry-run mode",
|
||||
meta: {
|
||||
workflowName: coderMeta.workflowName,
|
||||
attempt,
|
||||
passed: true,
|
||||
dryRunLog: "[dry-run] tester skipped external checks",
|
||||
reason: "dry-run mode",
|
||||
},
|
||||
} satisfies RoleResult<TesterMeta>;
|
||||
}
|
||||
|
||||
const prompt = testerPrompt({
|
||||
workflowName: coderMeta.workflowName,
|
||||
plannerSpec: {
|
||||
roles: plannerMeta.roles,
|
||||
flowTransitions: plannerMeta.flowTransitions,
|
||||
validationLoopsDesign: plannerMeta.validationLoopsDesign,
|
||||
externalDeps: plannerMeta.externalDeps,
|
||||
dataFlow: plannerMeta.dataFlow,
|
||||
},
|
||||
coderOutput: coderMeta.cursorOutput,
|
||||
nerveRoot,
|
||||
});
|
||||
|
||||
const run = await cursorAgent({
|
||||
prompt,
|
||||
mode: "ask",
|
||||
cwd: nerveRoot,
|
||||
env: null,
|
||||
timeoutMs: null,
|
||||
dryRun: false,
|
||||
});
|
||||
|
||||
if (!run.ok) {
|
||||
return {
|
||||
content: "tester agent failed",
|
||||
meta: {
|
||||
workflowName: coderMeta.workflowName,
|
||||
attempt,
|
||||
passed: false,
|
||||
dryRunLog: "",
|
||||
reason: `tester agent failed: ${formatSpawnFailure(run.error)}`,
|
||||
},
|
||||
} satisfies RoleResult<TesterMeta>;
|
||||
}
|
||||
|
||||
const text = run.value.trim();
|
||||
const pass = text.startsWith("PASS|");
|
||||
const fail = text.startsWith("FAIL|");
|
||||
if (!pass && !fail) {
|
||||
return {
|
||||
content: "tester format invalid",
|
||||
meta: {
|
||||
workflowName: coderMeta.workflowName,
|
||||
attempt,
|
||||
passed: false,
|
||||
dryRunLog: text,
|
||||
reason: "tester format invalid",
|
||||
},
|
||||
} satisfies RoleResult<TesterMeta>;
|
||||
}
|
||||
|
||||
const parts = text.split("|");
|
||||
const reason = parts[1] ?? "no reason";
|
||||
const log = parts.slice(2).join("|").trim();
|
||||
return {
|
||||
content: `${pass ? "PASS" : "FAIL"} — ${reason}`,
|
||||
meta: {
|
||||
workflowName: coderMeta.workflowName,
|
||||
attempt,
|
||||
passed: pass,
|
||||
dryRunLog: log,
|
||||
reason,
|
||||
},
|
||||
} satisfies RoleResult<TesterMeta>;
|
||||
};
|
||||
}
|
||||
34
workflows/workflow-generator/roles/tester/prompt.ts
Normal file
34
workflows/workflow-generator/roles/tester/prompt.ts
Normal file
@ -0,0 +1,34 @@
|
||||
export type TesterPromptParams = {
|
||||
workflowName: string;
|
||||
plannerSpec: object;
|
||||
coderOutput: string;
|
||||
nerveRoot: string;
|
||||
};
|
||||
|
||||
export function testerPrompt({
|
||||
workflowName,
|
||||
plannerSpec,
|
||||
coderOutput,
|
||||
nerveRoot: _nerveRoot,
|
||||
}: TesterPromptParams): string {
|
||||
return `You are testing a generated Nerve workflow by doing a dry-run review.
|
||||
|
||||
Workflow: ${workflowName}
|
||||
|
||||
Planner specification:
|
||||
${JSON.stringify(plannerSpec, null, 2)}
|
||||
|
||||
Coder output summary:
|
||||
${coderOutput.slice(0, 6000)}
|
||||
|
||||
Required checks:
|
||||
1) Verify role transitions are coherent and terminates to END.
|
||||
2) Verify generated workflow adheres to planner intent.
|
||||
3) Verify retry loops are explicit for recoverable failures.
|
||||
4) Verify no obvious runtime-breaking issue in generated index.ts.
|
||||
|
||||
Return exactly:
|
||||
PASS|<reason>|<compact markdown log>
|
||||
or
|
||||
FAIL|<reason>|<compact markdown log>`;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user