- 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
143 lines
4.0 KiB
TypeScript
143 lines
4.0 KiB
TypeScript
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>;
|
|
};
|
|
}
|