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:
@@ -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"`.
|
||||
|
||||
@@ -188,6 +188,7 @@ const defaultSettings = {
|
||||
overlapIgnorePaths: [],
|
||||
autoMerge: true,
|
||||
mergeStrategy: "direct",
|
||||
merger: { mode: "deterministic" },
|
||||
directMergeCommitStrategy: "auto",
|
||||
mergeIntegrationWorktree: "reuse-task-worktree",
|
||||
pushAfterMerge: false,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user