163 lines
5.3 KiB
TypeScript
163 lines
5.3 KiB
TypeScript
import type { Role, RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core";
|
|
import { cursorAgent, spawnSafe } from "@uncaged/nerve-workflow-utils";
|
|
import { lastMetaForRole } from "../../lib/meta-helpers.js";
|
|
import { formatSpawnFailure } from "../../lib/spawn-utils.js";
|
|
import { slugify } from "../../lib/text-utils.js";
|
|
import type { IntakeMeta } from "../intake/index.js";
|
|
import type { IssueReaderMeta } from "../issue-reader/index.js";
|
|
import type { PlannerMeta } from "../planner/index.js";
|
|
import { buildImplementerPrompt } from "./prompt.js";
|
|
|
|
export type ImplementerMeta = {
|
|
branchName: string | null;
|
|
changedFiles: string[] | null;
|
|
implementationOk: boolean;
|
|
attempt: number;
|
|
failureKind: "none" | "branch_failed" | "agent_failed" | "no_diff";
|
|
failureReason: string | null;
|
|
implementationLog: string | null;
|
|
};
|
|
|
|
export type BuildImplementerDeps = {
|
|
nerveRoot: string;
|
|
};
|
|
|
|
export function buildImplementerRole({ nerveRoot }: BuildImplementerDeps): Role<ImplementerMeta> {
|
|
return async (_start: StartStep, messages: WorkflowMessage[]): Promise<RoleResult<ImplementerMeta>> => {
|
|
const plannerMeta = lastMetaForRole<PlannerMeta>(messages, "planner");
|
|
const intakeMeta = lastMetaForRole<IntakeMeta>(messages, "intake");
|
|
const issueMeta = lastMetaForRole<IssueReaderMeta>(messages, "issue-reader");
|
|
const attempt = messages.filter((message) => message.role === "implementer").length + 1;
|
|
|
|
if (
|
|
plannerMeta === null ||
|
|
!plannerMeta.planningOk ||
|
|
plannerMeta.plan === null ||
|
|
intakeMeta === null ||
|
|
intakeMeta.issueNumber === null
|
|
) {
|
|
return {
|
|
content: "implementer cannot continue: missing planner or intake context",
|
|
meta: {
|
|
branchName: null,
|
|
changedFiles: null,
|
|
implementationOk: false,
|
|
attempt,
|
|
failureKind: "agent_failed",
|
|
failureReason: "missing planner/intake context",
|
|
implementationLog: null,
|
|
},
|
|
};
|
|
}
|
|
|
|
const slugSource = issueMeta?.issue?.title ?? plannerMeta.plan.split("\n")[0] ?? "fix";
|
|
const branchName = `fix/issue-${intakeMeta.issueNumber}-${slugify(slugSource)}`;
|
|
|
|
const existsResult = await spawnSafe("git", ["rev-parse", "--verify", `refs/heads/${branchName}`], {
|
|
cwd: nerveRoot,
|
|
env: null,
|
|
timeoutMs: 10_000,
|
|
});
|
|
const checkoutResult = existsResult.ok
|
|
? await spawnSafe("git", ["checkout", branchName], { cwd: nerveRoot, env: null, timeoutMs: 20_000 })
|
|
: await spawnSafe("git", ["checkout", "-b", branchName], { cwd: nerveRoot, env: null, timeoutMs: 20_000 });
|
|
|
|
if (!checkoutResult.ok) {
|
|
return {
|
|
content: `branch setup failed: ${formatSpawnFailure(checkoutResult.error)}`,
|
|
meta: {
|
|
branchName,
|
|
changedFiles: null,
|
|
implementationOk: false,
|
|
attempt,
|
|
failureKind: "branch_failed",
|
|
failureReason: formatSpawnFailure(checkoutResult.error),
|
|
implementationLog: null,
|
|
},
|
|
};
|
|
}
|
|
|
|
const prompt = buildImplementerPrompt({
|
|
issueNumber: intakeMeta.issueNumber,
|
|
plan: plannerMeta.plan,
|
|
targetFilesText: plannerMeta.targetFiles?.join("\n") ?? "(not specified)",
|
|
testCommandsText: plannerMeta.testCommands?.join("\n") ?? "(not specified)",
|
|
});
|
|
|
|
const agentResult = await cursorAgent({
|
|
prompt,
|
|
mode: "default",
|
|
model: "auto",
|
|
cwd: nerveRoot,
|
|
env: null,
|
|
timeoutMs: null,
|
|
});
|
|
if (!agentResult.ok) {
|
|
return {
|
|
content: `implementer agent failed: ${formatSpawnFailure(agentResult.error)}`,
|
|
meta: {
|
|
branchName,
|
|
changedFiles: null,
|
|
implementationOk: false,
|
|
attempt,
|
|
failureKind: "agent_failed",
|
|
failureReason: formatSpawnFailure(agentResult.error),
|
|
implementationLog: null,
|
|
},
|
|
};
|
|
}
|
|
|
|
const diffResult = await spawnSafe("git", ["diff", "--name-only"], {
|
|
cwd: nerveRoot,
|
|
env: null,
|
|
timeoutMs: 15_000,
|
|
});
|
|
if (!diffResult.ok) {
|
|
return {
|
|
content: `implementation finished but diff check failed: ${formatSpawnFailure(diffResult.error)}`,
|
|
meta: {
|
|
branchName,
|
|
changedFiles: null,
|
|
implementationOk: false,
|
|
attempt,
|
|
failureKind: "no_diff",
|
|
failureReason: formatSpawnFailure(diffResult.error),
|
|
implementationLog: agentResult.value,
|
|
},
|
|
};
|
|
}
|
|
|
|
const changedFiles = diffResult.value.stdout
|
|
.split("\n")
|
|
.map((file) => file.trim())
|
|
.filter((file) => file.length > 0);
|
|
if (changedFiles.length === 0) {
|
|
return {
|
|
content: "implementer made no local diff",
|
|
meta: {
|
|
branchName,
|
|
changedFiles: [],
|
|
implementationOk: false,
|
|
attempt,
|
|
failureKind: "no_diff",
|
|
failureReason: "git diff --name-only is empty",
|
|
implementationLog: agentResult.value,
|
|
},
|
|
};
|
|
}
|
|
|
|
return {
|
|
content: `branch ready: ${branchName}\nchanged files:\n${changedFiles.join("\n")}`,
|
|
meta: {
|
|
branchName,
|
|
changedFiles,
|
|
implementationOk: true,
|
|
attempt,
|
|
failureKind: "none",
|
|
failureReason: null,
|
|
implementationLog: agentResult.value,
|
|
},
|
|
};
|
|
};
|
|
}
|