- 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
847 lines
24 KiB
JavaScript
847 lines
24 KiB
JavaScript
// 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
|
|
};
|