FN-7029: split AI merge prompts and worktree helpers

Split the clean-room AI merger into smaller focused modules while preserving its public behavior.

- Extract prompt builders and review verdict parsing into merger-ai-prompts.
- Extract AI merge worktree lifecycle and cleanup helpers into merger-ai-worktree.
- Re-export the extracted APIs from merger-ai and cover prompt/verdict behavior with tests.
- Remove the merger-ai line-count baseline now that the file is under the guardrail.

Files changed:
 .../engine/src/__tests__/merger-ai-prompts.test.ts |  86 ++++
 packages/engine/src/merger-ai-prompts.ts           | 312 ++++++++++++
 packages/engine/src/merger-ai-worktree.ts          | 287 +++++++++++
 packages/engine/src/merger-ai.ts                   | 555 ++-------------------
 scripts/line-count-baseline.json                   |   1 -
 5 files changed, 723 insertions(+), 518 deletions(-)

Fusion-Task-Id: FN-7029

Fusion-Task-Lineage: 59adc31f-7386-4008-b74f-8fb9bbae078a
This commit is contained in:
gsxdsm
2026-06-25 20:12:20 -07:00
parent 0ae44992df
commit c0958bfd4f
5 changed files with 723 additions and 518 deletions

View File

@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import {
REVIEW_VERDICT_MARKER,
buildMergeSystemPrompt,
buildReviewSystemPrompt,
parseReviewVerdict,
} from "../merger-ai.js";
describe("merger-ai prompt/verdict re-exports", () => {
it("fails safe to a blocking reject for empty reviewer output", () => {
expect(parseReviewVerdict("")).toEqual({
verdict: "reject",
reasons: ["reviewer produced no output"],
severity: "blocking",
});
});
it("fails safe to a blocking reject for garbled reviewer output", () => {
expect(parseReviewVerdict("looks good, ship it")).toEqual({
verdict: "reject",
reasons: [
`reviewer did not emit a "${REVIEW_VERDICT_MARKER} approve|reject" line`,
],
severity: "blocking",
});
});
it("treats a reject without explicit severity as blocking", () => {
expect(
parseReviewVerdict(
`${REVIEW_VERDICT_MARKER} reject\n- dropped a conflict hunk`
)
).toEqual({
verdict: "reject",
reasons: ["dropped a conflict hunk"],
severity: "blocking",
});
});
it("honors explicit advisory severity and excludes severity from reasons", () => {
expect(
parseReviewVerdict(
`${REVIEW_VERDICT_MARKER} reject\nSEVERITY: advisory\n- commit message is vague`
)
).toEqual({
verdict: "reject",
reasons: ["commit message is vague"],
severity: "advisory",
});
});
it("parses the approve line", () => {
expect(
parseReviewVerdict(`All reviewed.\n${REVIEW_VERDICT_MARKER} approve`)
).toEqual({
verdict: "approve",
reasons: [],
});
});
it("extracts inline and bulleted reject reasons", () => {
expect(
parseReviewVerdict(
`${REVIEW_VERDICT_MARKER} reject: lost generated types\nSEVERITY: blocking\n1. dropped api.ts\n- skipped docs update`
)
).toEqual({
verdict: "reject",
reasons: [
"lost generated types",
"dropped api.ts",
"skipped docs update",
],
severity: "blocking",
});
});
it("keeps non-negotiable clean-room and verdict-marker prompt content", () => {
expect(buildMergeSystemPrompt()).toContain("## AI merge — clean room");
expect(buildMergeSystemPrompt()).toContain(
"Finish with exactly ONE new commit"
);
expect(buildReviewSystemPrompt()).toContain(REVIEW_VERDICT_MARKER);
expect(buildReviewSystemPrompt()).toContain("Do NOT edit, stage, commit");
});
});

View File

@@ -0,0 +1,312 @@
/*
FNXC:MergerAiSplit 2026-06-25-00:00:
FN-7029 extracts the AI-merge prompt builders and review verdict parser from merger-ai.ts so the sole FN-5633 clean-room merge path stays under the 2000-line guardrail without changing prompts, verdict parsing, or the public merger-ai.js import surface.
*/
import {
resolveAgentPrompt,
type AgentPromptsConfig,
type TaskComment,
} from "@fusion/core";
import { buildUserCommentsPromptSection } from "./agent-user-comments.js";
// ---------------------------------------------------------------------------
// Pure helpers (unit-tested)
// ---------------------------------------------------------------------------
export type AiMergeReviewSeverity = "blocking" | "advisory";
export interface AiMergeReviewVerdict {
verdict: "approve" | "reject";
reasons: string[];
severity?: AiMergeReviewSeverity;
}
export const REVIEW_VERDICT_MARKER = "REVIEW_VERDICT:";
const VERDICT_LINE_RE = /REVIEW_VERDICT:\s*(approve|reject)\b/i;
const SEVERITY_LINE_RE = /SEVERITY:\s*(blocking|advisory)\b/i;
/**
* Parse the reviewer's free-form output. Fail-safe: no/garbled output, or a
* rejection with no explicit severity, is treated as a BLOCKING reject — an
* ambiguous reviewer can never wave wrong code through, nor silently downgrade
* to advisory.
*/
export function parseReviewVerdict(
agentText: string | null | undefined
): AiMergeReviewVerdict {
const text = (agentText ?? "").trim();
if (!text)
return {
verdict: "reject",
reasons: ["reviewer produced no output"],
severity: "blocking",
};
const lines = text.split(/\r?\n/);
let verdictLineIndex = -1;
let decision: "approve" | "reject" | null = null;
for (let i = lines.length - 1; i >= 0; i--) {
const m = lines[i].match(VERDICT_LINE_RE);
if (m) {
decision = m[1].toLowerCase() as "approve" | "reject";
verdictLineIndex = i;
break;
}
}
if (!decision) {
return {
verdict: "reject",
reasons: [
`reviewer did not emit a "${REVIEW_VERDICT_MARKER} approve|reject" line`,
],
severity: "blocking",
};
}
if (decision === "approve") return { verdict: "approve", reasons: [] };
const severity: AiMergeReviewSeverity = SEVERITY_LINE_RE.test(text)
? (text.match(SEVERITY_LINE_RE)![1].toLowerCase() as AiMergeReviewSeverity)
: "blocking";
return {
verdict: "reject",
reasons: extractRejectReasons(lines, verdictLineIndex),
severity,
};
}
function extractRejectReasons(
lines: string[],
verdictLineIndex: number
): string[] {
const reasons: string[] = [];
const inline = lines[verdictLineIndex]
.replace(VERDICT_LINE_RE, "")
.replace(/^[\s:–—-]+/, "")
.trim();
if (inline) reasons.push(inline);
for (let i = verdictLineIndex + 1; i < lines.length; i++) {
if (SEVERITY_LINE_RE.test(lines[i])) continue;
const cleaned = lines[i].replace(/^\s*(?:[-*•]|\d+[.)])\s+/, "").trim();
if (cleaned) reasons.push(cleaned);
}
if (reasons.length === 0)
reasons.push("reviewer rejected the merge without a stated reason");
return reasons;
}
export function buildMergeSystemPrompt(
agentPrompts?: AgentPromptsConfig
): string {
// Base persona is the editable "merger" agent prompt (Settings → Prompts);
// the non-negotiable clean-room / verification / commit-trailer rules below
// are always appended so a custom prompt can't drop them.
const base = resolveAgentPrompt("merger", agentPrompts).trim();
return [
base,
base ? "" : undefined,
"## AI merge — clean room",
"You are on a CLEAN, detached checkout at the integration branch's current",
"tip. Land the task branch's work as a single commit.",
"",
"Constraints:",
" - Resolve every conflict in favor of the task branch's intent; never drop",
" the task's changes to make a conflict go away.",
" - Do not make edits unrelated to reconciling the two branches.",
" - Do NOT push, force-push, or run `git update-ref` / `git reset --hard`",
" on any other branch. Only commit on this detached HEAD.",
" - Finish with exactly ONE new commit on HEAD containing the task's work.",
"",
"Verify before committing:",
" - After resolving the merge, run the project's checks — tests, type-check,",
" and lint (discover them from the project config / package.json scripts,",
" e.g. test / typecheck / lint / build).",
" - FIX any NEW failure the merge or conflict resolution introduced (a check",
" that passed on the task branch or the integration tip but fails on the",
" merged tree). You do not need to fix failures that were already broken on",
" the integration branch beforehand, but never commit a merge that adds new",
" test, type-check, or lint failures.",
"",
"Commit message:",
" - The subject line must CONCISELY SUMMARIZE the squashed changes in",
' imperative mood (e.g. "add X", "fix Y") based on the actual diff — do',
" not just restate the task title.",
" - The commit BODY must include:",
" 1) one short narrative summary line,",
" 2) a bullet list of key changes, and",
" 3) a `Files changed:` section populated from `git diff --stat`.",
" - Include the task-id prefix and the trailer lines EXACTLY as given in the",
" task instructions (they associate the commit with the board task).",
]
.filter((l) => l !== undefined)
.join("\n");
}
export function buildMergePrompt(input: {
taskId: string;
branch: string;
integrationBranch: string;
tipSha: string;
/** Task title — a HINT for the summary, not the literal subject. */
taskTitle?: string;
/** Whether to prefix the subject with the task id. */
includeTaskId: boolean;
/** Required trailers to append (board association). */
trailers: string[];
correctiveReasons?: string[];
userComments?: TaskComment[];
}): string {
const subjectShape = input.includeTaskId
? `"${input.taskId}: <concise imperative summary of the squashed changes>"`
: `"<concise imperative summary of the squashed changes>"`;
const trailerArgs = input.trailers
.map((t) => ` -m ${JSON.stringify(t)}`)
.join("");
const lines = [
`Merge branch "${input.branch}" into "${
input.integrationBranch
}" (HEAD is detached at ${short(input.tipSha)}).`,
"",
"Steps:",
` 1. Run: git merge --squash ${input.branch}`,
" 2. If there are conflicts, resolve them (favor the task's intent), then `git add` the resolved files.",
" 3. Build a merge body from the staged squash diff:",
" - one short narrative summary line",
" - bullet list of key changes",
" - `Files changed:` + the output of `git diff --stat`",
" 4. Commit the staged result as a SINGLE commit whose subject summarizes the",
` actual changes${
input.taskTitle
? ` (task title hint: ${JSON.stringify(input.taskTitle)})`
: ""
}, including the body above and required trailers:`,
` git commit -m ${subjectShape} -m "<narrative + bullet list + Files changed: ...>"${trailerArgs}`,
" Keep the trailer line(s) verbatim — they link the commit to the board task.",
" 5. Verify `git log --oneline ${tip}..HEAD` shows exactly one new commit and `git status` is clean.".replace(
"${tip}",
short(input.tipSha)
),
"",
"If `git merge --squash` reports the branch is already up to date (nothing to",
"merge), do nothing and leave HEAD unchanged.",
];
const userCommentsSection = buildUserCommentsPromptSection(
input.userComments ?? []
);
if (userCommentsSection) {
lines.push("", userCommentsSection);
}
if (input.correctiveReasons && input.correctiveReasons.length > 0) {
lines.push(
"",
"A prior attempt was REJECTED by review. Redo the merge from the clean tip",
"and address each of these problems:",
...input.correctiveReasons.map((r) => ` - ${r}`)
);
}
return lines.join("\n");
}
export function buildReviewSystemPrompt(): string {
return [
"You are an adversarial, read-only merge reviewer. Do NOT edit, stage, commit,",
"or run any mutating git command. Audit the squash commit that is about to be",
"merged into the integration branch and decide whether it is safe to land.",
"",
"Investigate with read-only commands (git show, git diff, git log, cat, grep).",
"Judge on four axes:",
" 1. Completeness — does the squash contain ALL of the task branch's intended",
" changes? Flag any hunk silently dropped during conflict resolution.",
" 2. No collateral — does it touch only files within the task's footprint?",
" 3. Conflict soundness — were conflicts resolved coherently (both sides'",
" intent preserved), not by blindly discarding one side?",
" 4. Commit message — read `git show`'s message: the subject must concisely",
" and ACCURATELY summarize the actual changes (not vague, not a mere",
" restatement of the task title, not misleading). A poor/inaccurate",
" message is an ADVISORY concern (it should be rewritten on retry, but",
" must not block the merge).",
"",
"Bias toward rejection when uncertain.",
"",
`End with a single decision line: "${REVIEW_VERDICT_MARKER} approve" or`,
`"${REVIEW_VERDICT_MARKER} reject". When rejecting, add a "SEVERITY:" line:`,
" - SEVERITY: blocking — a correctness problem (dropped/lost task changes,",
" incomplete squash, or a conflict resolution that discards intent). The",
" merge must NOT land if this is unfixable.",
" - SEVERITY: advisory — a quality/style concern that does not risk",
" correctness; acceptable to land if unresolved.",
"Then list each concrete reason as a bullet.",
].join("\n");
}
export function buildReviewPrompt(input: {
taskId: string;
branch: string;
integrationBranch: string;
tipSha: string;
squashSha: string;
diffStat: string;
priorReasons?: string[];
userComments?: TaskComment[];
}): string {
const lines = [
`Review the squash merge for task ${input.taskId} (branch ${input.branch} → ${input.integrationBranch}).`,
"",
`Integration tip: ${short(input.tipSha)}`,
`Squash commit: ${short(input.squashSha)}`,
"",
"Inspect with:",
` git show ${input.squashSha}`,
` git diff ${input.tipSha}..${input.squashSha}`,
"",
"Files changed (git diff --stat):",
input.diffStat.trim() || "(none reported)",
];
const userCommentsSection = buildUserCommentsPromptSection(
input.userComments ?? []
);
if (userCommentsSection) {
lines.push("", userCommentsSection);
}
if (input.priorReasons && input.priorReasons.length > 0) {
lines.push(
"",
"A prior pass rejected an earlier attempt for these reasons — confirm they",
"are now resolved:",
...input.priorReasons.map((r) => ` - ${r}`)
);
}
return lines.join("\n");
}
export function buildStashResolveSystemPrompt(): string {
return [
"You are resolving a conflict between the user's restored local working-tree",
"edits and the freshly-merged integration branch. The user's uncommitted work",
"was stashed, the checkout fast-forwarded to the new tip, and re-applying the",
"stash produced conflicts.",
"",
"Resolve every conflict marker so BOTH sides are preserved: keep the user's",
"local intent AND the upstream changes that just landed. Stage each resolved",
"file with `git add`.",
"",
"Do NOT commit, stash, reset, checkout a different branch, or run update-ref.",
"Leave the resolved changes in the working tree as the user's uncommitted edits.",
].join("\n");
}
export function buildStashResolvePrompt(conflictedFiles: string[]): string {
return [
"Re-applying your stashed local changes onto the updated branch conflicted.",
"",
"Conflicted files:",
...conflictedFiles.map((f) => ` - ${f}`),
"",
"Resolve each file's conflict markers (preserve both the local edits and the",
"upstream changes), then `git add` it. Do not commit.",
].join("\n");
}
function short(sha: string): string {
return /^[0-9a-f]{7,40}$/i.test(sha) ? sha.slice(0, 8) : sha;
}

View File

@@ -0,0 +1,287 @@
/*
FNXC:MergerAiSplit 2026-06-25-00:00:
FN-7029 extracts AI-merge worktree lifecycle helpers from merger-ai.ts so the sole FN-5633 clean-room merge path stays under the 2000-line guardrail without changing cleanup semantics or public merger-ai.js exports.
FNXC:MergerAiSplit 2026-06-25-00:00:
Keep importing MIN_TEMP_WORKTREE_REAP_AGE_MS from self-healing.js here. Do not reverse the dependency: self-healing owns the stale-temp age policy and merger-ai-worktree only consumes it for pre-merge pruning, preserving the established self-healing import-cycle constraint.
*/
import { execFile } from "node:child_process";
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
import { rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { isAbsolute, join, relative } from "node:path";
import { promisify } from "node:util";
import type { Settings } from "@fusion/core";
import { activeSessionRegistry } from "./active-session-registry.js";
import type { RunAuditor } from "./run-audit.js";
import { MIN_TEMP_WORKTREE_REAP_AGE_MS } from "./self-healing.js";
import { resolveAiMergeRootPath, resolveLegacyAiMergeRootPath } from "./worktree-paths.js";
const execFileAsync = promisify(execFile);
async function git(args: string[], cwd: string, opts: { timeout?: number } = {}): Promise<string> {
const { stdout } = await execFileAsync("git", args, {
cwd,
encoding: "utf-8",
timeout: opts.timeout ?? 120_000,
maxBuffer: 16 * 1024 * 1024,
});
return stdout.trim();
}
function getErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function getErrorStringProperty(err: unknown, key: "stderr" | "code"): string | undefined {
if (!err || typeof err !== "object" || !(key in err)) return undefined;
const value = (err as Record<string, unknown>)[key];
return typeof value === "string" && value.trim() ? value : undefined;
}
function describeCleanupError(err: unknown): string {
const stderr = getErrorStringProperty(err, "stderr");
const message = getErrorMessage(err);
return stderr ? `${message}: ${stderr.trim()}` : message;
}
export function isBenignAbsentWorktreeError(err: unknown): boolean {
const code = getErrorStringProperty(err, "code");
if (code === "ENOENT") return true;
const description = describeCleanupError(err);
return /is not a working tree|No such file or directory|spawn\s+.*\bENOENT\b/i.test(description);
}
function ensureAiMergeRootIgnored(projectRootDir: string, settings?: Settings): void {
const excludePath = join(projectRootDir, ".git", "info", "exclude");
if (!existsSync(excludePath)) return;
try {
const current = readFileSync(excludePath, "utf-8");
const legacyAiMergeRoot = resolveLegacyAiMergeRootPath(projectRootDir);
const legacyRelativeAiMergeRoot = relative(projectRootDir, legacyAiMergeRoot);
const entries = [`${legacyRelativeAiMergeRoot.replaceAll("\\", "/")}/`];
const aiMergeRoot = resolveAiMergeRootPath(projectRootDir, settings);
const relativeAiMergeRoot = relative(projectRootDir, aiMergeRoot);
if (relativeAiMergeRoot && !relativeAiMergeRoot.startsWith("..") && !isAbsolute(relativeAiMergeRoot)) {
entries.push(`${relativeAiMergeRoot.replaceAll("\\", "/")}/`);
}
const missing = entries.filter((entry) => !current.split(/\r?\n/).includes(entry));
if (missing.length > 0) {
appendFileSync(excludePath, `${current.endsWith("\n") ? "" : "\n"}${missing.join("\n")}\n`);
}
} catch {
// Best effort only: cleanup still removes the root contents, and existing
// projects generally ignore .fusion already.
}
}
export function resolveAiMergeRoot(projectRootDir: string, settings?: Settings): string {
const root = resolveAiMergeRootPath(projectRootDir, settings);
mkdirSync(root, { recursive: true });
ensureAiMergeRootIgnored(projectRootDir, settings);
return root;
}
function getAiMergeTempSearchRoots(projectRootDir: string, settings?: Settings): string[] {
const roots = [resolveAiMergeRoot(projectRootDir, settings), resolveLegacyAiMergeRootPath(projectRootDir), tmpdir()];
const testWorkerRoot = process.env.FUSION_TEST_WORKER_ROOT;
if (testWorkerRoot) {
try {
for (const entry of readdirSync(testWorkerRoot)) {
if (entry.startsWith("redir-")) roots.push(join(testWorkerRoot, entry));
}
} catch {
// Best effort for the test harness' bounded temp-dir redirection root.
}
}
return Array.from(new Set(roots));
}
export async function pruneExistingAiMergeWorktrees(
taskId: string,
projectRootDir: string,
audit: RunAuditor,
log: (message: string) => Promise<void>,
settings?: Settings,
): Promise<number> {
const prefix = `fusion-ai-merge-${taskId.toLowerCase()}-`;
const tempRoots = getAiMergeTempSearchRoots(projectRootDir, settings);
let pruned = 0;
let cleanupAttempted = false;
for (const tempRoot of tempRoots) {
let entries: string[];
try {
entries = readdirSync(tempRoot).filter((entry) => entry.startsWith(prefix));
} catch (err: unknown) {
/*
FNXC:AiMerge 2026-06-24-23:10:
An absent ai-merge search root is the NORMAL case, not an error: the clean-room directory
(e.g. `<repo>/.fusion/ai-merge`) is created lazily only when an AI-merge worktree is made, so a
workspace sub-repo that has never been AI-merged has no such dir. ENOENT therefore means
"nothing to prune" — skip it silently rather than emitting an alarming warning on every merge.
Only non-ENOENT failures are surfaced, and only a non-ENOENT failure on the system tmpdir
(which always exists) remains fatal.
*/
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") continue;
await log(`AI merge pre-merge prune: failed to read ${tempRoot}: ${getErrorMessage(err)}`);
if (tempRoot === tmpdir()) throw err;
continue;
}
for (const entry of entries) {
const candidatePath = join(tempRoot, entry);
let canonicalPath = candidatePath;
try {
canonicalPath = realpathSync(candidatePath);
} catch {
canonicalPath = candidatePath;
}
if (activeSessionRegistry.isPathActive(canonicalPath) || activeSessionRegistry.isPathActive(candidatePath)) {
await log(`AI merge pre-merge prune: skipping active worktree ${canonicalPath}`);
continue;
}
try {
const stat = statSync(canonicalPath);
const ageMs = Date.now() - stat.mtimeMs;
if (ageMs < MIN_TEMP_WORKTREE_REAP_AGE_MS) {
await log(`AI merge pre-merge prune: skipping too-new worktree ${canonicalPath} (age ${Math.max(0, Math.round(ageMs))}ms)`);
continue;
}
} catch (err: unknown) {
await log(`AI merge pre-merge prune: failed to stat ${canonicalPath}: ${getErrorMessage(err)} — skipping candidate`);
continue;
}
let alreadyAbsent = false;
try {
cleanupAttempted = true;
await execFileAsync("git", ["worktree", "remove", "--force", canonicalPath], {
cwd: projectRootDir,
timeout: 30_000,
});
} catch (err: unknown) {
if (isBenignAbsentWorktreeError(err)) {
alreadyAbsent = true;
await log(`AI merge pre-merge prune: worktree ${canonicalPath} was already absent/de-registered; treating cleanup as idempotent`);
} else {
await log(`AI merge pre-merge prune: git worktree remove failed for ${canonicalPath}: ${describeCleanupError(err)} — falling back to filesystem removal`);
}
}
try {
cleanupAttempted = true;
rmSync(canonicalPath, { recursive: true, force: true });
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalPath, metadata: { taskId, mergeRoot: canonicalPath, phase: "pre-merge-prune", success: true, ...(alreadyAbsent ? { alreadyAbsent: true, idempotent: true } : {}) } });
pruned++;
} catch (err: unknown) {
if (isBenignAbsentWorktreeError(err)) {
await log(`AI merge pre-merge prune: worktree ${canonicalPath} was already absent during filesystem cleanup; treating cleanup as idempotent`);
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalPath, metadata: { taskId, mergeRoot: canonicalPath, phase: "pre-merge-prune", success: true, alreadyAbsent: true, idempotent: true } });
pruned++;
continue;
}
const error = getErrorMessage(err);
const code = getErrorStringProperty(err, "code");
await log(`AI merge pre-merge prune: filesystem rm failed for ${canonicalPath}${code ? ` (${code})` : ""}: ${error}`);
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalPath, metadata: { taskId, mergeRoot: canonicalPath, phase: "pre-merge-prune", success: false, error, ...(code ? { code } : {}) } });
}
}
}
if (cleanupAttempted) {
try {
await execFileAsync("git", ["worktree", "prune"], { cwd: projectRootDir, timeout: 30_000 });
} catch (err: unknown) {
await log(`AI merge pre-merge prune: git worktree prune failed: ${describeCleanupError(err)}`);
}
}
return pruned;
}
export async function cleanupAiMergeWorktree(input: {
taskId: string;
mergeRoot: string;
projectRootDir: string;
worktreeAdded: boolean;
audit: RunAuditor;
log: (message: string) => Promise<void>;
gitRunner?: typeof git;
rmRunner?: typeof rm;
}): Promise<void> {
const { taskId, mergeRoot, projectRootDir, worktreeAdded, audit, log, gitRunner = git, rmRunner = rm } = input;
let canonicalRoot = mergeRoot;
try {
canonicalRoot = realpathSync(mergeRoot);
} catch {
canonicalRoot = mergeRoot;
}
const removalTargets = canonicalRoot === mergeRoot ? [mergeRoot] : [canonicalRoot, mergeRoot];
const cleanupMetadata = { taskId, mergeRoot: canonicalRoot, requestedMergeRoot: mergeRoot };
let alreadyAbsent = false;
if (worktreeAdded) {
if (!existsSync(canonicalRoot) && !existsSync(mergeRoot)) {
alreadyAbsent = true;
await log(`AI merge cleanup: worktree ${canonicalRoot} was already absent before git removal; treating cleanup as idempotent`);
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: true, alreadyAbsent: true, idempotent: true, code: "ENOENT" } });
} else {
try {
await gitRunner(["worktree", "remove", "--force", canonicalRoot], projectRootDir);
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: true } });
} catch (err: unknown) {
const error = describeCleanupError(err);
const code = getErrorStringProperty(err, "code");
if (isBenignAbsentWorktreeError(err)) {
alreadyAbsent = true;
await log(`AI merge cleanup: worktree ${canonicalRoot} was already absent/de-registered during git removal; treating cleanup as idempotent`);
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: true, alreadyAbsent: true, idempotent: true, error, ...(code ? { code } : {}) } });
} else {
await log(`AI merge cleanup: git worktree remove failed for ${canonicalRoot}${code ? ` (${code})` : ""}: ${error}`);
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: false, error, ...(code ? { code } : {}) } });
}
}
}
}
let removedFromFilesystem = false;
for (const target of removalTargets) {
try {
await rmRunner(target, { recursive: true, force: true });
await audit.git({ type: "merge:ai-worktree-cleanup", target, metadata: { ...cleanupMetadata, phase: "fs-rm", path: target, success: true, ...(alreadyAbsent ? { alreadyAbsent: true, idempotent: true } : {}) } });
removedFromFilesystem = true;
break;
} catch (err: unknown) {
const error = getErrorMessage(err);
const code = getErrorStringProperty(err, "code");
if (isBenignAbsentWorktreeError(err)) {
await log(`AI merge cleanup: worktree ${target} was already absent during filesystem cleanup; treating cleanup as idempotent`);
await audit.git({ type: "merge:ai-worktree-cleanup", target, metadata: { ...cleanupMetadata, phase: "fs-rm", path: target, success: true, alreadyAbsent: true, idempotent: true, error, ...(code ? { code } : {}) } });
removedFromFilesystem = true;
break;
}
await log(`AI merge cleanup: filesystem rm failed for ${target}${code ? ` (${code})` : ""}: ${error}`);
await audit.git({ type: "merge:ai-worktree-cleanup", target, metadata: { ...cleanupMetadata, phase: "fs-rm", path: target, success: false, error, ...(code ? { code } : {}) } });
}
}
if (!removedFromFilesystem) {
await log(`AI merge cleanup: filesystem cleanup did not remove ${canonicalRoot}; continuing to prune worktree metadata`);
}
try {
await gitRunner(["worktree", "prune"], projectRootDir, { timeout: 30_000 });
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-prune", success: true } });
} catch (err: unknown) {
const error = describeCleanupError(err);
const code = getErrorStringProperty(err, "code");
await log(`AI merge cleanup: git worktree prune failed after removing ${canonicalRoot}${code ? ` (${code})` : ""}: ${error}`);
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-prune", success: false, error, ...(code ? { code } : {}) } });
}
}

View File

@@ -37,29 +37,25 @@
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { isAbsolute, join, relative } from "node:path";
import { realpathSync } from "node:fs";
import { mkdtemp } from "node:fs/promises";
import { join } from "node:path";
import {
assertNotWorkspaceTaskMerge,
buildTaskLineageTrailer,
evaluateNoCommitsNoOpFinalize,
getPrimaryPrInfo,
getTaskMergeBlocker,
resolveAgentPrompt,
resolvePersistAgentThinkingLog,
resolveTaskMergeTarget,
resolveValidatorSettingsModel,
type AgentPromptsConfig,
type MergeDetails,
type MergeResult,
type Settings,
type Task,
type TaskComment,
type TaskStore,
} from "@fusion/core";
import { buildUserCommentsPromptSection, selectUserCommentsForAgentContext } from "./agent-user-comments.js";
import { selectUserCommentsForAgentContext } from "./agent-user-comments.js";
import { resolveTaskWorkingBranch } from "./worktree-names.js";
import { resolveIntegrationBranch } from "./integration-branch.js";
import { advanceIntegrationBranchRef } from "./merger-ref-update-advance.js";
@@ -74,16 +70,28 @@ import { createLogger } from "./logger.js";
import { captureSingleCommitLandedMetadata, type MergerOptions } from "./merger.js";
import { installWorktreeDependencies } from "./merge-dependency-sync.js";
import { activeSessionRegistry } from "./active-session-registry.js";
import { MIN_TEMP_WORKTREE_REAP_AGE_MS } from "./self-healing.js";
import { resolveAiMergeRootPath, resolveLegacyAiMergeRootPath } from "./worktree-paths.js";
/*
FNXC:Workspace 2026-06-22-14:10 (Phase D review G — cycle dissolved):
`isRepoLanded` + `FUSION_TASK_ID_TRAILER_KEY` moved to the dependency-free `workspace-land-predicate`
module so self-healing can import the predicate without re-entering the self-healing ↔ merger-ai
import cycle (merger-ai already imports `MIN_TEMP_WORKTREE_REAP_AGE_MS` from self-healing).
import cycle (merger-ai-worktree imports `MIN_TEMP_WORKTREE_REAP_AGE_MS` from self-healing).
*/
import { isRepoLanded, FUSION_TASK_ID_TRAILER_KEY } from "./workspace-land-predicate.js";
import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js";
import {
cleanupAiMergeWorktree,
pruneExistingAiMergeWorktrees,
resolveAiMergeRoot,
} from "./merger-ai-worktree.js";
import {
buildMergePrompt,
buildMergeSystemPrompt,
buildReviewPrompt,
buildReviewSystemPrompt,
buildStashResolvePrompt,
buildStashResolveSystemPrompt,
parseReviewVerdict,
} from "./merger-ai-prompts.js";
const execFileAsync = promisify(execFile);
const aiMergeLog = createLogger("merger-ai");
@@ -113,257 +121,16 @@ function getErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function getErrorStringProperty(err: unknown, key: "stderr" | "code"): string | undefined {
if (!err || typeof err !== "object" || !(key in err)) return undefined;
const value = (err as Record<string, unknown>)[key];
return typeof value === "string" && value.trim() ? value : undefined;
function short(sha: string): string {
return /^[0-9a-f]{7,40}$/i.test(sha) ? sha.slice(0, 8) : sha;
}
function describeCleanupError(err: unknown): string {
const stderr = getErrorStringProperty(err, "stderr");
const message = getErrorMessage(err);
return stderr ? `${message}: ${stderr.trim()}` : message;
}
export function isBenignAbsentWorktreeError(err: unknown): boolean {
const code = getErrorStringProperty(err, "code");
if (code === "ENOENT") return true;
const description = describeCleanupError(err);
return /is not a working tree|No such file or directory|spawn\s+.*\bENOENT\b/i.test(description);
}
function ensureAiMergeRootIgnored(projectRootDir: string, settings?: Settings): void {
const excludePath = join(projectRootDir, ".git", "info", "exclude");
if (!existsSync(excludePath)) return;
try {
const current = readFileSync(excludePath, "utf-8");
const legacyAiMergeRoot = resolveLegacyAiMergeRootPath(projectRootDir);
const legacyRelativeAiMergeRoot = relative(projectRootDir, legacyAiMergeRoot);
const entries = [`${legacyRelativeAiMergeRoot.replaceAll("\\", "/")}/`];
const aiMergeRoot = resolveAiMergeRootPath(projectRootDir, settings);
const relativeAiMergeRoot = relative(projectRootDir, aiMergeRoot);
if (relativeAiMergeRoot && !relativeAiMergeRoot.startsWith("..") && !isAbsolute(relativeAiMergeRoot)) {
entries.push(`${relativeAiMergeRoot.replaceAll("\\", "/")}/`);
}
const missing = entries.filter((entry) => !current.split(/\r?\n/).includes(entry));
if (missing.length > 0) {
appendFileSync(excludePath, `${current.endsWith("\n") ? "" : "\n"}${missing.join("\n")}\n`);
}
} catch {
// Best effort only: cleanup still removes the root contents, and existing
// projects generally ignore .fusion already.
}
}
export function resolveAiMergeRoot(projectRootDir: string, settings?: Settings): string {
const root = resolveAiMergeRootPath(projectRootDir, settings);
mkdirSync(root, { recursive: true });
ensureAiMergeRootIgnored(projectRootDir, settings);
return root;
}
function getAiMergeTempSearchRoots(projectRootDir: string, settings?: Settings): string[] {
const roots = [resolveAiMergeRoot(projectRootDir, settings), resolveLegacyAiMergeRootPath(projectRootDir), tmpdir()];
const testWorkerRoot = process.env.FUSION_TEST_WORKER_ROOT;
if (testWorkerRoot) {
try {
for (const entry of readdirSync(testWorkerRoot)) {
if (entry.startsWith("redir-")) roots.push(join(testWorkerRoot, entry));
}
} catch {
// Best effort for the test harness' bounded temp-dir redirection root.
}
}
return Array.from(new Set(roots));
}
export async function pruneExistingAiMergeWorktrees(
taskId: string,
projectRootDir: string,
audit: RunAuditor,
log: (message: string) => Promise<void>,
settings?: Settings,
): Promise<number> {
const prefix = `fusion-ai-merge-${taskId.toLowerCase()}-`;
const tempRoots = getAiMergeTempSearchRoots(projectRootDir, settings);
let pruned = 0;
let cleanupAttempted = false;
for (const tempRoot of tempRoots) {
let entries: string[];
try {
entries = readdirSync(tempRoot).filter((entry) => entry.startsWith(prefix));
} catch (err: unknown) {
/*
FNXC:AiMerge 2026-06-24-23:10:
An absent ai-merge search root is the NORMAL case, not an error: the clean-room directory
(e.g. `<repo>/.fusion/ai-merge`) is created lazily only when an AI-merge worktree is made, so a
workspace sub-repo that has never been AI-merged has no such dir. ENOENT therefore means
"nothing to prune" — skip it silently rather than emitting an alarming warning on every merge.
Only non-ENOENT failures are surfaced, and only a non-ENOENT failure on the system tmpdir
(which always exists) remains fatal.
*/
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") continue;
await log(`AI merge pre-merge prune: failed to read ${tempRoot}: ${getErrorMessage(err)}`);
if (tempRoot === tmpdir()) throw err;
continue;
}
for (const entry of entries) {
const candidatePath = join(tempRoot, entry);
let canonicalPath = candidatePath;
try {
canonicalPath = realpathSync(candidatePath);
} catch {
canonicalPath = candidatePath;
}
if (activeSessionRegistry.isPathActive(canonicalPath) || activeSessionRegistry.isPathActive(candidatePath)) {
await log(`AI merge pre-merge prune: skipping active worktree ${canonicalPath}`);
continue;
}
try {
const stat = statSync(canonicalPath);
const ageMs = Date.now() - stat.mtimeMs;
if (ageMs < MIN_TEMP_WORKTREE_REAP_AGE_MS) {
await log(`AI merge pre-merge prune: skipping too-new worktree ${canonicalPath} (age ${Math.max(0, Math.round(ageMs))}ms)`);
continue;
}
} catch (err: unknown) {
await log(`AI merge pre-merge prune: failed to stat ${canonicalPath}: ${getErrorMessage(err)} — skipping candidate`);
continue;
}
let alreadyAbsent = false;
try {
cleanupAttempted = true;
await execFileAsync("git", ["worktree", "remove", "--force", canonicalPath], {
cwd: projectRootDir,
timeout: 30_000,
});
} catch (err: unknown) {
if (isBenignAbsentWorktreeError(err)) {
alreadyAbsent = true;
await log(`AI merge pre-merge prune: worktree ${canonicalPath} was already absent/de-registered; treating cleanup as idempotent`);
} else {
await log(`AI merge pre-merge prune: git worktree remove failed for ${canonicalPath}: ${describeCleanupError(err)} — falling back to filesystem removal`);
}
}
try {
cleanupAttempted = true;
rmSync(canonicalPath, { recursive: true, force: true });
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalPath, metadata: { taskId, mergeRoot: canonicalPath, phase: "pre-merge-prune", success: true, ...(alreadyAbsent ? { alreadyAbsent: true, idempotent: true } : {}) } });
pruned++;
} catch (err: unknown) {
if (isBenignAbsentWorktreeError(err)) {
await log(`AI merge pre-merge prune: worktree ${canonicalPath} was already absent during filesystem cleanup; treating cleanup as idempotent`);
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalPath, metadata: { taskId, mergeRoot: canonicalPath, phase: "pre-merge-prune", success: true, alreadyAbsent: true, idempotent: true } });
pruned++;
continue;
}
const error = getErrorMessage(err);
const code = getErrorStringProperty(err, "code");
await log(`AI merge pre-merge prune: filesystem rm failed for ${canonicalPath}${code ? ` (${code})` : ""}: ${error}`);
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalPath, metadata: { taskId, mergeRoot: canonicalPath, phase: "pre-merge-prune", success: false, error, ...(code ? { code } : {}) } });
}
}
}
if (cleanupAttempted) {
try {
await execFileAsync("git", ["worktree", "prune"], { cwd: projectRootDir, timeout: 30_000 });
} catch (err: unknown) {
await log(`AI merge pre-merge prune: git worktree prune failed: ${describeCleanupError(err)}`);
}
}
return pruned;
}
export async function cleanupAiMergeWorktree(input: {
taskId: string;
mergeRoot: string;
projectRootDir: string;
worktreeAdded: boolean;
audit: RunAuditor;
log: (message: string) => Promise<void>;
gitRunner?: typeof git;
rmRunner?: typeof rm;
}): Promise<void> {
const { taskId, mergeRoot, projectRootDir, worktreeAdded, audit, log, gitRunner = git, rmRunner = rm } = input;
let canonicalRoot = mergeRoot;
try {
canonicalRoot = realpathSync(mergeRoot);
} catch {
canonicalRoot = mergeRoot;
}
const removalTargets = canonicalRoot === mergeRoot ? [mergeRoot] : [canonicalRoot, mergeRoot];
const cleanupMetadata = { taskId, mergeRoot: canonicalRoot, requestedMergeRoot: mergeRoot };
let alreadyAbsent = false;
if (worktreeAdded) {
if (!existsSync(canonicalRoot) && !existsSync(mergeRoot)) {
alreadyAbsent = true;
await log(`AI merge cleanup: worktree ${canonicalRoot} was already absent before git removal; treating cleanup as idempotent`);
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: true, alreadyAbsent: true, idempotent: true, code: "ENOENT" } });
} else {
try {
await gitRunner(["worktree", "remove", "--force", canonicalRoot], projectRootDir);
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: true } });
} catch (err: unknown) {
const error = describeCleanupError(err);
const code = getErrorStringProperty(err, "code");
if (isBenignAbsentWorktreeError(err)) {
alreadyAbsent = true;
await log(`AI merge cleanup: worktree ${canonicalRoot} was already absent/de-registered during git removal; treating cleanup as idempotent`);
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: true, alreadyAbsent: true, idempotent: true, error, ...(code ? { code } : {}) } });
} else {
await log(`AI merge cleanup: git worktree remove failed for ${canonicalRoot}${code ? ` (${code})` : ""}: ${error}`);
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-remove", success: false, error, ...(code ? { code } : {}) } });
}
}
}
}
let removedFromFilesystem = false;
for (const target of removalTargets) {
try {
await rmRunner(target, { recursive: true, force: true });
await audit.git({ type: "merge:ai-worktree-cleanup", target, metadata: { ...cleanupMetadata, phase: "fs-rm", path: target, success: true, ...(alreadyAbsent ? { alreadyAbsent: true, idempotent: true } : {}) } });
removedFromFilesystem = true;
break;
} catch (err: unknown) {
const error = getErrorMessage(err);
const code = getErrorStringProperty(err, "code");
if (isBenignAbsentWorktreeError(err)) {
await log(`AI merge cleanup: worktree ${target} was already absent during filesystem cleanup; treating cleanup as idempotent`);
await audit.git({ type: "merge:ai-worktree-cleanup", target, metadata: { ...cleanupMetadata, phase: "fs-rm", path: target, success: true, alreadyAbsent: true, idempotent: true, error, ...(code ? { code } : {}) } });
removedFromFilesystem = true;
break;
}
await log(`AI merge cleanup: filesystem rm failed for ${target}${code ? ` (${code})` : ""}: ${error}`);
await audit.git({ type: "merge:ai-worktree-cleanup", target, metadata: { ...cleanupMetadata, phase: "fs-rm", path: target, success: false, error, ...(code ? { code } : {}) } });
}
}
if (!removedFromFilesystem) {
await log(`AI merge cleanup: filesystem cleanup did not remove ${canonicalRoot}; continuing to prune worktree metadata`);
}
try {
await gitRunner(["worktree", "prune"], projectRootDir, { timeout: 30_000 });
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-prune", success: true } });
} catch (err: unknown) {
const error = describeCleanupError(err);
const code = getErrorStringProperty(err, "code");
await log(`AI merge cleanup: git worktree prune failed after removing ${canonicalRoot}${code ? ` (${code})` : ""}: ${error}`);
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalRoot, metadata: { ...cleanupMetadata, phase: "git-prune", success: false, error, ...(code ? { code } : {}) } });
}
}
export {
cleanupAiMergeWorktree,
isBenignAbsentWorktreeError,
pruneExistingAiMergeWorktrees,
resolveAiMergeRoot,
} from "./merger-ai-worktree.js";
/** Trailers that associate the squash commit with its board task: the
* `Fusion-Task-Id` trailer plus the canonical lineage trailer when available.
@@ -412,263 +179,17 @@ async function ensureCommitTaskMetadata(
// Pure helpers (unit-tested)
// ---------------------------------------------------------------------------
export type AiMergeReviewSeverity = "blocking" | "advisory";
export interface AiMergeReviewVerdict {
verdict: "approve" | "reject";
reasons: string[];
severity?: AiMergeReviewSeverity;
}
export const REVIEW_VERDICT_MARKER = "REVIEW_VERDICT:";
const VERDICT_LINE_RE = /REVIEW_VERDICT:\s*(approve|reject)\b/i;
const SEVERITY_LINE_RE = /SEVERITY:\s*(blocking|advisory)\b/i;
/**
* Parse the reviewer's free-form output. Fail-safe: no/garbled output, or a
* rejection with no explicit severity, is treated as a BLOCKING reject — an
* ambiguous reviewer can never wave wrong code through, nor silently downgrade
* to advisory.
*/
export function parseReviewVerdict(agentText: string | null | undefined): AiMergeReviewVerdict {
const text = (agentText ?? "").trim();
if (!text) return { verdict: "reject", reasons: ["reviewer produced no output"], severity: "blocking" };
const lines = text.split(/\r?\n/);
let verdictLineIndex = -1;
let decision: "approve" | "reject" | null = null;
for (let i = lines.length - 1; i >= 0; i--) {
const m = lines[i].match(VERDICT_LINE_RE);
if (m) {
decision = m[1].toLowerCase() as "approve" | "reject";
verdictLineIndex = i;
break;
}
}
if (!decision) {
return {
verdict: "reject",
reasons: [`reviewer did not emit a "${REVIEW_VERDICT_MARKER} approve|reject" line`],
severity: "blocking",
};
}
if (decision === "approve") return { verdict: "approve", reasons: [] };
const severity: AiMergeReviewSeverity = SEVERITY_LINE_RE.test(text)
? (text.match(SEVERITY_LINE_RE)![1].toLowerCase() as AiMergeReviewSeverity)
: "blocking";
return { verdict: "reject", reasons: extractRejectReasons(lines, verdictLineIndex), severity };
}
function extractRejectReasons(lines: string[], verdictLineIndex: number): string[] {
const reasons: string[] = [];
const inline = lines[verdictLineIndex].replace(VERDICT_LINE_RE, "").replace(/^[\s:–—-]+/, "").trim();
if (inline) reasons.push(inline);
for (let i = verdictLineIndex + 1; i < lines.length; i++) {
if (SEVERITY_LINE_RE.test(lines[i])) continue;
const cleaned = lines[i].replace(/^\s*(?:[-*•]|\d+[.)])\s+/, "").trim();
if (cleaned) reasons.push(cleaned);
}
if (reasons.length === 0) reasons.push("reviewer rejected the merge without a stated reason");
return reasons;
}
export function buildMergeSystemPrompt(agentPrompts?: AgentPromptsConfig): string {
// Base persona is the editable "merger" agent prompt (Settings → Prompts);
// the non-negotiable clean-room / verification / commit-trailer rules below
// are always appended so a custom prompt can't drop them.
const base = resolveAgentPrompt("merger", agentPrompts).trim();
return [
base,
base ? "" : undefined,
"## AI merge — clean room",
"You are on a CLEAN, detached checkout at the integration branch's current",
"tip. Land the task branch's work as a single commit.",
"",
"Constraints:",
" - Resolve every conflict in favor of the task branch's intent; never drop",
" the task's changes to make a conflict go away.",
" - Do not make edits unrelated to reconciling the two branches.",
" - Do NOT push, force-push, or run `git update-ref` / `git reset --hard`",
" on any other branch. Only commit on this detached HEAD.",
" - Finish with exactly ONE new commit on HEAD containing the task's work.",
"",
"Verify before committing:",
" - After resolving the merge, run the project's checks — tests, type-check,",
" and lint (discover them from the project config / package.json scripts,",
" e.g. test / typecheck / lint / build).",
" - FIX any NEW failure the merge or conflict resolution introduced (a check",
" that passed on the task branch or the integration tip but fails on the",
" merged tree). You do not need to fix failures that were already broken on",
" the integration branch beforehand, but never commit a merge that adds new",
" test, type-check, or lint failures.",
"",
"Commit message:",
" - The subject line must CONCISELY SUMMARIZE the squashed changes in",
" imperative mood (e.g. \"add X\", \"fix Y\") based on the actual diff — do",
" not just restate the task title.",
" - The commit BODY must include:",
" 1) one short narrative summary line,",
" 2) a bullet list of key changes, and",
" 3) a `Files changed:` section populated from `git diff --stat`.",
" - Include the task-id prefix and the trailer lines EXACTLY as given in the",
" task instructions (they associate the commit with the board task).",
].filter((l) => l !== undefined).join("\n");
}
export function buildMergePrompt(input: {
taskId: string;
branch: string;
integrationBranch: string;
tipSha: string;
/** Task title — a HINT for the summary, not the literal subject. */
taskTitle?: string;
/** Whether to prefix the subject with the task id. */
includeTaskId: boolean;
/** Required trailers to append (board association). */
trailers: string[];
correctiveReasons?: string[];
userComments?: TaskComment[];
}): string {
const subjectShape = input.includeTaskId
? `"${input.taskId}: <concise imperative summary of the squashed changes>"`
: `"<concise imperative summary of the squashed changes>"`;
const trailerArgs = input.trailers.map((t) => ` -m ${JSON.stringify(t)}`).join("");
const lines = [
`Merge branch "${input.branch}" into "${input.integrationBranch}" (HEAD is detached at ${short(input.tipSha)}).`,
"",
"Steps:",
` 1. Run: git merge --squash ${input.branch}`,
" 2. If there are conflicts, resolve them (favor the task's intent), then `git add` the resolved files.",
" 3. Build a merge body from the staged squash diff:",
" - one short narrative summary line",
" - bullet list of key changes",
" - `Files changed:` + the output of `git diff --stat`",
" 4. Commit the staged result as a SINGLE commit whose subject summarizes the",
` actual changes${input.taskTitle ? ` (task title hint: ${JSON.stringify(input.taskTitle)})` : ""}, including the body above and required trailers:`,
` git commit -m ${subjectShape} -m "<narrative + bullet list + Files changed: ...>"${trailerArgs}`,
" Keep the trailer line(s) verbatim — they link the commit to the board task.",
" 5. Verify `git log --oneline ${tip}..HEAD` shows exactly one new commit and `git status` is clean.".replace("${tip}", short(input.tipSha)),
"",
"If `git merge --squash` reports the branch is already up to date (nothing to",
"merge), do nothing and leave HEAD unchanged.",
];
const userCommentsSection = buildUserCommentsPromptSection(input.userComments ?? []);
if (userCommentsSection) {
lines.push("", userCommentsSection);
}
if (input.correctiveReasons && input.correctiveReasons.length > 0) {
lines.push(
"",
"A prior attempt was REJECTED by review. Redo the merge from the clean tip",
"and address each of these problems:",
...input.correctiveReasons.map((r) => ` - ${r}`),
);
}
return lines.join("\n");
}
export function buildReviewSystemPrompt(): string {
return [
"You are an adversarial, read-only merge reviewer. Do NOT edit, stage, commit,",
"or run any mutating git command. Audit the squash commit that is about to be",
"merged into the integration branch and decide whether it is safe to land.",
"",
"Investigate with read-only commands (git show, git diff, git log, cat, grep).",
"Judge on four axes:",
" 1. Completeness — does the squash contain ALL of the task branch's intended",
" changes? Flag any hunk silently dropped during conflict resolution.",
" 2. No collateral — does it touch only files within the task's footprint?",
" 3. Conflict soundness — were conflicts resolved coherently (both sides'",
" intent preserved), not by blindly discarding one side?",
" 4. Commit message — read `git show`'s message: the subject must concisely",
" and ACCURATELY summarize the actual changes (not vague, not a mere",
" restatement of the task title, not misleading). A poor/inaccurate",
" message is an ADVISORY concern (it should be rewritten on retry, but",
" must not block the merge).",
"",
"Bias toward rejection when uncertain.",
"",
`End with a single decision line: "${REVIEW_VERDICT_MARKER} approve" or`,
`"${REVIEW_VERDICT_MARKER} reject". When rejecting, add a "SEVERITY:" line:`,
" - SEVERITY: blocking — a correctness problem (dropped/lost task changes,",
" incomplete squash, or a conflict resolution that discards intent). The",
" merge must NOT land if this is unfixable.",
" - SEVERITY: advisory — a quality/style concern that does not risk",
" correctness; acceptable to land if unresolved.",
"Then list each concrete reason as a bullet.",
].join("\n");
}
export function buildReviewPrompt(input: {
taskId: string;
branch: string;
integrationBranch: string;
tipSha: string;
squashSha: string;
diffStat: string;
priorReasons?: string[];
userComments?: TaskComment[];
}): string {
const lines = [
`Review the squash merge for task ${input.taskId} (branch ${input.branch} → ${input.integrationBranch}).`,
"",
`Integration tip: ${short(input.tipSha)}`,
`Squash commit: ${short(input.squashSha)}`,
"",
"Inspect with:",
` git show ${input.squashSha}`,
` git diff ${input.tipSha}..${input.squashSha}`,
"",
"Files changed (git diff --stat):",
input.diffStat.trim() || "(none reported)",
];
const userCommentsSection = buildUserCommentsPromptSection(input.userComments ?? []);
if (userCommentsSection) {
lines.push("", userCommentsSection);
}
if (input.priorReasons && input.priorReasons.length > 0) {
lines.push(
"",
"A prior pass rejected an earlier attempt for these reasons — confirm they",
"are now resolved:",
...input.priorReasons.map((r) => ` - ${r}`),
);
}
return lines.join("\n");
}
export function buildStashResolveSystemPrompt(): string {
return [
"You are resolving a conflict between the user's restored local working-tree",
"edits and the freshly-merged integration branch. The user's uncommitted work",
"was stashed, the checkout fast-forwarded to the new tip, and re-applying the",
"stash produced conflicts.",
"",
"Resolve every conflict marker so BOTH sides are preserved: keep the user's",
"local intent AND the upstream changes that just landed. Stage each resolved",
"file with `git add`.",
"",
"Do NOT commit, stash, reset, checkout a different branch, or run update-ref.",
"Leave the resolved changes in the working tree as the user's uncommitted edits.",
].join("\n");
}
export function buildStashResolvePrompt(conflictedFiles: string[]): string {
return [
"Re-applying your stashed local changes onto the updated branch conflicted.",
"",
"Conflicted files:",
...conflictedFiles.map((f) => ` - ${f}`),
"",
"Resolve each file's conflict markers (preserve both the local edits and the",
"upstream changes), then `git add` it. Do not commit.",
].join("\n");
}
function short(sha: string): string {
return /^[0-9a-f]{7,40}$/i.test(sha) ? sha.slice(0, 8) : sha;
}
export {
REVIEW_VERDICT_MARKER,
buildMergePrompt,
buildMergeSystemPrompt,
buildReviewPrompt,
buildReviewSystemPrompt,
buildStashResolvePrompt,
buildStashResolveSystemPrompt,
parseReviewVerdict,
} from "./merger-ai-prompts.js";
export type { AiMergeReviewSeverity, AiMergeReviewVerdict } from "./merger-ai-prompts.js";
// ---------------------------------------------------------------------------
// Errors

View File

@@ -92,7 +92,6 @@
"packages/engine/src/agent-heartbeat.ts": 4660,
"packages/engine/src/agent-tools.ts": 3986,
"packages/engine/src/executor.ts": 16743,
"packages/engine/src/merger-ai.ts": 2050,
"packages/engine/src/merger.ts": 12886,
"packages/engine/src/pi.ts": 2507,
"packages/engine/src/project-engine.ts": 4030,