小橘 a469f30b42 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
2026-04-28 08:48:23 +00:00

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>;
};
}