- 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
191 lines
5.9 KiB
TypeScript
191 lines
5.9 KiB
TypeScript
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>;
|
|
};
|
|
}
|