feat(FN-5633): AI merge commit summary + task trailers, verify-and-fix, editable prompt

- Commit message: the merge agent writes a concise change-summary subject (not
  just the task title), and every landed squash carries board-association
  trailers — `Fusion-Task-Id: <taskId>` plus the canonical lineage trailer when
  the task has a lineageId — guaranteed via an idempotent amend
  (ensureTaskTrailersOnHead) even if the agent omits them.
- Verify-and-fix: the merge prompt instructs the agent to run the project's
  tests / type-check / lint after resolving the merge and fix any NEW failure
  the merge introduced (not pre-existing breakage) before committing.
- Editable prompt: the AI merge agent's base persona is the editable "merger"
  role prompt (Settings -> Prompts); the clean-room / verification / trailer
  rules are always appended so a custom prompt can't drop them.
- Reviewer model: reviewer uses the project reviewer/validator model lane
  (resolveValidatorSettingsModel); the bespoke merger.reviewerModel setting is
  removed.

Changeset updated to cover all AI merger changes.

Tests: trailer present on landed commit (+ lineage when set), editable-prompt
incorporation, new-breakage verification wording.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-28 20:14:13 -07:00
parent 1153b0932e
commit c1d558c765
4 changed files with 128 additions and 14 deletions

View File

@@ -16,6 +16,16 @@ How it works:
Settings: `merger.mode` (`ai` default / `deterministic` legacy), `merger.reviewerModel`, `merger.maxReviewPasses` (default 3), surfaced in Settings → Merge. When AI merge is on, the legacy merge-mechanics settings (integration worktree, conflict strategy, overlap guard, post-merge audit, direct-commit routing) are hidden since they do not apply.
Commit message: the AI agent writes the squash commit subject as a concise summary of the actual changes (not just the task title), and every landed squash carries the board-association trailers — `Fusion-Task-Id: <taskId>` plus the canonical lineage trailer when the task has a `lineageId` — guaranteed via an idempotent amend even if the agent omits them, so the board associates the commit with the task.
Verification: the merge agent is instructed to run the project's tests, type-check, and lint after resolving the merge and to fix any NEW failure the merge introduced (without being on the hook for pre-existing breakage) before committing.
Editable prompt: the AI merge agent's base persona is the editable "merger" role prompt (Settings → Prompts); the non-negotiable clean-room / verification / commit-trailer rules are always appended so a custom prompt can't drop them.
Reviewer model: the reviewer agent uses the project's reviewer/validator model lane (`resolveValidatorSettingsModel`: project validator → global validator → project default), not a merge-specific setting.
No-branch guard: a missing task branch is a benign no-op only when the task was never executed or was already merged (branch cleaned up on re-process); if the task was executed (`baseCommitSha` recorded) and was never merged, the merge fails loudly rather than silently marking the task done.
The legacy `aiMergeTask` pipeline is retained unchanged and used when `merger.mode: "deterministic"`.
Tests: `merger-ai.test.ts` covers the verdict parser, clean merge, blocking hard-fail (no advance), advisory land, empty no-op, per-task target branch isolation, missing-target-branch error, and `landSquash` (clean ff, other-branch update-ref, dirty stash-restore, AI-resolved restore conflict). Engine merge-orchestration tests that assert the legacy path are pinned to `merger.mode: "deterministic"`.

View File

@@ -188,6 +188,7 @@ const defaultSettings = {
overlapIgnorePaths: [],
autoMerge: true,
mergeStrategy: "direct",
merger: { mode: "deterministic" },
directMergeCommitStrategy: "auto",
mergeIntegrationWorktree: "reuse-task-worktree",
pushAfterMerge: false,

View File

@@ -115,6 +115,20 @@ describe("parseReviewVerdict", () => {
expect(buildReviewSystemPrompt().toLowerCase()).toContain("read-only");
expect(buildMergeSystemPrompt().toLowerCase()).toContain("conflict");
});
it("merge system prompt enforces new-breakage verification + uses the editable merger prompt", () => {
expect(buildMergeSystemPrompt().toLowerCase()).toContain("type-check");
expect(buildMergeSystemPrompt()).toMatch(/new failure/i);
// A custom 'merger' role prompt is incorporated as the base, while the hard
// rules (verification + trailers) are still appended.
const cfg = {
templates: [{ id: "custom-merger", role: "merger", name: "Custom", prompt: "CUSTOM MERGER PERSONA" }],
roleAssignments: { merger: "custom-merger" },
} as any;
const p = buildMergeSystemPrompt(cfg);
expect(p).toContain("CUSTOM MERGER PERSONA");
expect(p).toContain("Verify before committing");
});
});
describe("runAiMerge", () => {
@@ -134,11 +148,28 @@ describe("runAiMerge", () => {
expect(mainAfter).not.toBe(mainBefore);
// The squash landed the feature file.
expect(existsSync(join(dir, "feature.txt"))).toBe(true);
// The landed commit carries the board-association trailer even though the
// (mock) merge agent committed without it — ensureTaskTrailersOnHead adds it.
expect(git(dir, "log -1 --pretty=%B main")).toContain("Fusion-Task-Id: FN-1");
// Task moved to done + event emitted.
expect(store.moveTask).toHaveBeenCalledWith("FN-1", "done");
expect(emitted.some((e) => e.event === "task:merged")).toBe(true);
});
it("includes the lineage trailer when the task has a lineageId", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
const { store } = makeStore(dir, { lineageId: "lin-abc123" });
await runAiMerge(store, dir, "FN-1", { manual: true }, {
mergeAgent: realMergeAgent("fusion/fn-1"),
reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"),
});
const msg = git(dir, "log -1 --pretty=%B main");
expect(msg).toContain("Fusion-Task-Id: FN-1");
expect(msg).toContain("lin-abc123"); // canonical lineage trailer
});
it("hard-fails (no advance) on a blocking veto past the budget", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
const { store } = makeStore(dir);

View File

@@ -34,9 +34,12 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
buildTaskLineageTrailer,
getTaskMergeBlocker,
resolveAgentPrompt,
resolveTaskMergeTarget,
resolveValidatorSettingsModel,
type AgentPromptsConfig,
type MergeResult,
type Settings,
type Task,
@@ -78,6 +81,30 @@ async function gitOk(args: string[], cwd: string): Promise<boolean> {
}
}
const FUSION_TASK_ID_TRAILER_KEY = "Fusion-Task-Id";
/** Trailers that associate the squash commit with its board task: the
* `Fusion-Task-Id` trailer plus the canonical lineage trailer when available.
* These are what the board's commit→task association parses. */
function taskTrailers(taskId: string, lineageId?: string | null): string[] {
const trailers = [`${FUSION_TASK_ID_TRAILER_KEY}: ${taskId}`];
if (lineageId) trailers.push(buildTaskLineageTrailer(lineageId));
return trailers;
}
/** Idempotently guarantee the task trailers are on HEAD — a safety net so board
* association holds even if the AI agent omitted them from its commit. */
async function ensureTaskTrailersOnHead(mergeRoot: string, trailers: string[]): Promise<void> {
const existing = await git(["log", "-1", "--pretty=%B"], mergeRoot).catch(() => "");
const missing = trailers.filter((t) => !existing.includes(t));
if (missing.length === 0) return;
const args = ["-c", "trailer.ifExists=addIfDifferent", "commit", "--amend", "--no-edit"];
for (const t of missing) args.push("--trailer", t);
await git(args, mergeRoot).catch((err: unknown) => {
aiMergeLog.warn(`failed to amend task trailers onto squash (${err instanceof Error ? err.message : String(err)})`);
});
}
// ---------------------------------------------------------------------------
// Pure helpers (unit-tested)
// ---------------------------------------------------------------------------
@@ -143,11 +170,17 @@ function extractRejectReasons(lines: string[], verdictLineIndex: number): string
return reasons;
}
export function buildMergeSystemPrompt(): string {
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 [
"You are merging a task branch into the integration branch. You are on a",
"CLEAN, detached checkout at the integration branch's current tip. Your job",
"is to land the task branch's work as a single commit.",
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",
@@ -156,7 +189,24 @@ export function buildMergeSystemPrompt(): string {
" - 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.",
].join("\n");
"",
"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.",
" - 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: {
@@ -164,16 +214,28 @@ export function buildMergePrompt(input: {
branch: string;
integrationBranch: string;
tipSha: string;
subject: 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[];
}): 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. Commit the staged result as a single commit: git commit -m ${JSON.stringify(input.subject)}`,
" 3. 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 required trailers:`,
` git commit -m ${subjectShape}${trailerArgs}`,
" Keep the trailer line(s) verbatim — they link the commit to the board task.",
" 4. 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",
@@ -630,10 +692,13 @@ export async function runAiMerge(
}
const maxPasses = Math.max(0, Math.trunc(settings.merger?.maxReviewPasses ?? 3));
const mergeAgent = deps.mergeAgent ?? makeMutatingAgent(store, settings, taskId, options, audit, buildMergeSystemPrompt());
const mergeAgent = deps.mergeAgent ?? makeMutatingAgent(store, settings, taskId, options, audit, buildMergeSystemPrompt(settings.agentPrompts));
const reviewAgent = deps.reviewAgent ?? makeReviewAgent(store, settings, taskId, options, audit);
const stashResolveAgent = deps.stashResolveAgent ?? makeMutatingAgent(store, settings, taskId, options, audit, buildStashResolveSystemPrompt());
const subject = (task.title?.trim() || `Merge ${taskId}`).split("\n")[0];
const includeTaskId = settings.includeTaskIdInCommit !== false;
// Trailers that link the squash commit to the board task (FN-id + lineage).
const trailers = taskTrailers(taskId, task.lineageId);
const taskTitle = task.title?.trim() ? task.title.split("\n")[0] : undefined;
await setStatus("merging");
let advanceRetries = 0;
@@ -652,7 +717,7 @@ export async function runAiMerge(
// 2 + 3. Merge + review loop (corrective passes).
const squashSha = await mergeAndReview({
mergeRoot, branch, integrationBranch, tipSha, subject, taskId,
mergeRoot, branch, integrationBranch, tipSha, taskTitle, includeTaskId, trailers, taskId,
maxPasses, mergeAgent, reviewAgent, audit, log, setStatus, signal: options.signal,
});
@@ -694,7 +759,9 @@ async function mergeAndReview(input: {
branch: string;
integrationBranch: string;
tipSha: string;
subject: string;
taskTitle?: string;
includeTaskId: boolean;
trailers: string[];
taskId: string;
maxPasses: number;
mergeAgent: (cwd: string, prompt: string) => Promise<void>;
@@ -704,7 +771,7 @@ async function mergeAndReview(input: {
setStatus: (status: string | null) => Promise<unknown>;
signal?: AbortSignal;
}): Promise<string | null> {
const { mergeRoot, branch, integrationBranch, tipSha, subject, taskId, maxPasses, mergeAgent, reviewAgent, audit, log, setStatus, signal } = input;
const { mergeRoot, branch, integrationBranch, tipSha, taskTitle, includeTaskId, trailers, taskId, maxPasses, mergeAgent, reviewAgent, audit, log, setStatus, signal } = input;
let priorReasons: string[] = [];
for (let attempt = 0; ; attempt++) {
@@ -719,13 +786,18 @@ async function mergeAndReview(input: {
await log(`AI merge: corrective re-merge (pass ${attempt}/${maxPasses}) addressing: ${priorReasons.join("; ")}`);
}
await mergeAgent(mergeRoot, buildMergePrompt({
taskId, branch, integrationBranch, tipSha, subject,
taskId, branch, integrationBranch, tipSha, taskTitle, includeTaskId, trailers,
correctiveReasons: priorReasons.length ? priorReasons : undefined,
}));
const head = await git(["rev-parse", "HEAD"], mergeRoot);
let head = await git(["rev-parse", "HEAD"], mergeRoot);
if (head === tipSha) return null; // empty merge — nothing landed
// Guarantee the board-association trailers are on the squash even if the
// agent omitted them — this amends HEAD, so re-read the sha afterwards.
await ensureTaskTrailersOnHead(mergeRoot, trailers);
head = await git(["rev-parse", "HEAD"], mergeRoot);
await setStatus("reviewing");
const diffStat = await git(["diff", "--stat", `${tipSha}..${head}`], mergeRoot);
const verdict = parseReviewVerdict(await reviewAgent(mergeRoot, buildReviewPrompt({