806 lines
22 KiB
TypeScript
806 lines
22 KiB
TypeScript
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";
|
|
|
|
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;
|
|
};
|
|
|
|
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;
|
|
};
|
|
};
|
|
|
|
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.string().default(""),
|
|
validationLoopsDesign: z.string().default(""),
|
|
externalDeps: z.string().default(""),
|
|
dataFlow: z.string().default(""),
|
|
planMarkdown: z.string().default(""),
|
|
});
|
|
|
|
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") {
|
|
return last.meta.workflowName.trim().length > 0 ? "coder" : 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;
|