@dev-loops/core 1.0.3 → 1.0.4-pre.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Main-checkout fast-forward command shape (#1596).
2
+ * Main-checkout fast-forward flow.
3
3
  *
4
4
  * The dev-loop merges remotely (`gh pr merge` → origin/main) but neither the merge
5
5
  * procedure nor the post-merge hooks fast-forwarded the main checkout's local
@@ -8,7 +8,7 @@
8
8
  * code — re-introducing the CI-wait stall every PR (e.g. #1531's fix was invisible
9
9
  * until the main checkout caught up).
10
10
  *
11
- * This module owns the shared, dependency-free command string both harness hooks
11
+ * This module owns the shared, dependency-free step-wise flow both harness hooks
12
12
  * (Pi `post-merge-update`, Claude `post-tool-use-merge`) run after a successful
13
13
  * merge. It is best-effort and NON-BLOCKING: `--ff-only` refuses a diverged `main`
14
14
  * without rewriting history, so a diverged checkout fails the merge step cleanly and
@@ -16,10 +16,11 @@
16
16
  * push). `mainCheckout` is POSIX single-quoted so consumer checkout paths containing
17
17
  * spaces or shell metacharacters cannot break or inject into the shell string.
18
18
  *
19
- * The `merge --ff-only` is guarded to only run when the main checkout is currently on
20
- * `main`, so a non-`main` checkout (detached HEAD, or another branch checked out)
21
- * warns-and-continues instead of fast-forwarding the wrong branch. No `git switch` is
22
- * performed (a state change) — only the guard test runs.
19
+ * `syncMainCheckout` inspects the current ref BEFORE submitting any merge command, so
20
+ * a non-`main` checkout (detached HEAD, or another branch checked out) is classified
21
+ * and reported as an action-required diagnostic instead of fast-forwarding the wrong
22
+ * branch. No `git switch`/`checkout`/`reset` command is ever submitted for a non-main
23
+ * checkout — only read-only inspection commands run.
23
24
  *
24
25
  * No imports so this file vendors into the `.claude/hooks/` bundle unchanged
25
26
  * (vendored modules may only import `node:` builtins or relative paths).
@@ -27,15 +28,18 @@
27
28
  import path from "node:path";
28
29
 
29
30
  /**
30
- * Timeout (ms) for the `git worktree list` resolution step (the fetch-half budget;
31
- * a separate fetch timeout isn't applied — the fetch runs inline within the merge
32
- * command under `MAIN_CHECKOUT_FF_MERGE_TIMEOUT_MS`).
31
+ * Timeout (ms) for the `git worktree list` resolution step (the harness hook's own
32
+ * budget; `syncMainCheckout`'s step-wise `fetch`/`rev-parse`/`merge` commands each
33
+ * run under `MAIN_CHECKOUT_FF_MERGE_TIMEOUT_MS` instead).
33
34
  */
34
35
  export const MAIN_CHECKOUT_FF_FETCH_TIMEOUT_MS = 60_000;
35
36
 
36
- /** Timeout (ms) for the `git merge --ff-only origin/main` half. */
37
+ /** Timeout (ms) for each step of `syncMainCheckout`'s fetch/rev-parse/merge flow. */
37
38
  export const MAIN_CHECKOUT_FF_MERGE_TIMEOUT_MS = 60_000;
38
39
 
40
+ /** Stable diagnostic kind for a main checkout proven not to be on `main`. */
41
+ export const MAIN_CHECKOUT_NOT_ON_MAIN_KIND = "main_checkout_not_on_main";
42
+
39
43
  /**
40
44
  * POSIX single-quote a path so spaces/shell metacharacters in a consumer's checkout
41
45
  * path cannot break or inject into the shell string.
@@ -45,17 +49,165 @@ function shellQuotePath(value) {
45
49
  }
46
50
 
47
51
  /**
48
- * Build the best-effort main-checkout fast-forward command string.
52
+ * Classify a main checkout's current full symbolic ref (`git rev-parse
53
+ * --symbolic-full-name HEAD` output) into one of the four states `syncMainCheckout`
54
+ * acts on. `--symbolic-full-name` (unlike `--abbrev-ref`) is never subject to
55
+ * `core.warnAmbiguousRefs` renaming a branch to `heads/<name>` when a tag or other ref
56
+ * shares its short name.
57
+ *
58
+ * @param {unknown} symbolicFullName - Raw (already-trimmed or not) `--symbolic-full-name
59
+ * HEAD` output.
60
+ * @returns {"main" | "other_branch" | "detached" | "unreadable"}
61
+ */
62
+ export function classifyMainCheckoutRef(symbolicFullName) {
63
+ if (typeof symbolicFullName !== "string") {
64
+ return "unreadable";
65
+ }
66
+ const trimmed = symbolicFullName.trim();
67
+ if (!trimmed) {
68
+ return "unreadable";
69
+ }
70
+ if (trimmed === "HEAD") {
71
+ return "detached";
72
+ }
73
+ if (trimmed === "refs/heads/main") {
74
+ return "main";
75
+ }
76
+ if (trimmed.startsWith("refs/heads/")) {
77
+ return "other_branch";
78
+ }
79
+ return "unreadable";
80
+ }
81
+
82
+ /**
83
+ * Build the `main_checkout_not_on_main` diagnostic, or `null` when any required field
84
+ * is missing or invalid. The one shared rendered `message` names the kind, the
85
+ * absolute checkout path, the current ref, the behind count, that no fast-forward
86
+ * happened, that the action is non-fatal, and the manual recovery — never a
87
+ * reset/force suggestion.
88
+ *
89
+ * @param {{ mainCheckout: unknown, ref: unknown, behindCount: unknown }} fields
90
+ * @returns {{ kind: string, severity: "error", mainCheckout: string, ref: string, behindCount: number, message: string } | null}
91
+ */
92
+ export function buildMainCheckoutNotOnMainDiagnostic({ mainCheckout, ref, behindCount } = {}) {
93
+ if (typeof mainCheckout !== "string" || !path.isAbsolute(mainCheckout)) {
94
+ return null;
95
+ }
96
+ if (typeof ref !== "string" || !ref.trim()) {
97
+ return null;
98
+ }
99
+ if (!Number.isInteger(behindCount) || behindCount < 0) {
100
+ return null;
101
+ }
102
+ const message =
103
+ `[dev-loops] post-merge: ${MAIN_CHECKOUT_NOT_ON_MAIN_KIND} — the main checkout at '${mainCheckout}' ` +
104
+ `is on ${ref}, ${behindCount} commit(s) behind origin/main after fetch. No fast-forward was performed; ` +
105
+ `this is non-fatal. Reconcile manually: preserve any local-only commits, then check out main and ` +
106
+ `fast-forward it to origin/main.`;
107
+ return { kind: MAIN_CHECKOUT_NOT_ON_MAIN_KIND, severity: "error", mainCheckout, ref, behindCount, message };
108
+ }
109
+
110
+ /**
111
+ * Run one shell-command step through the harness-supplied `run` adapter, normalizing
112
+ * a thrown/rejected `run` into the same `{ ok: false, reason }` shape as an adapter
113
+ * that resolves a failure — callers never need a second failure path.
114
+ *
115
+ * @param {(command: string) => Promise<{ ok: boolean, stdout?: string, reason?: string }>} run
116
+ * @param {string} command
117
+ * @returns {Promise<{ ok: true, stdout: string } | { ok: false, reason: string }>}
118
+ */
119
+ async function runStep(run, command) {
120
+ try {
121
+ const result = await run(command);
122
+ if (result?.ok) {
123
+ return { ok: true, stdout: typeof result.stdout === "string" ? result.stdout : "" };
124
+ }
125
+ return { ok: false, reason: result?.reason || "command failed" };
126
+ } catch (error) {
127
+ return { ok: false, reason: error?.message || String(error) };
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Best-effort, step-wise main-checkout sync.
133
+ *
134
+ * After a successful `fetch origin main`, inspects the checkout's current ref BEFORE
135
+ * submitting any merge command:
136
+ * - `main` → `git merge --ff-only origin/main`; ok → `fast_forwarded`; a diverged
137
+ * main fails the merge step cleanly → `skipped`.
138
+ * - detached HEAD or another named branch → never merged/switched/reset; instead
139
+ * the post-fetch `HEAD..origin/main` behind count is measured and reported as a
140
+ * `not_on_main` diagnostic (see `buildMainCheckoutNotOnMainDiagnostic`).
141
+ * Any step that cannot prove its outcome (fetch failure, unreadable ref, a failed or
142
+ * empty short-SHA resolution, a failed or non-numeric behind-count read) is reported
143
+ * as `skipped` instead of a partial diagnostic.
49
144
  *
50
145
  * @param {string} mainCheckout - Absolute path to the main (primary) git checkout.
51
- * @returns {string} `git -C '<main>' fetch origin main && [ "$(git -C '<main>' rev-parse --abbrev-ref HEAD)" = main ] && git -C '<main>' merge --ff-only origin/main` (path POSIX single-quoted; merge only runs when the main checkout is on `main`)
146
+ * @param {(command: string) => Promise<{ ok: boolean, stdout?: string, reason?: string }>} run
147
+ * Harness adapter that executes one shell command and resolves its outcome (may also
148
+ * throw — treated as a failed step).
149
+ * @returns {Promise<
150
+ * | { status: "fast_forwarded" }
151
+ * | { status: "skipped", reason: string }
152
+ * | { status: "not_on_main", diagnostic: ReturnType<typeof buildMainCheckoutNotOnMainDiagnostic> }
153
+ * >}
52
154
  */
53
- export function buildMainCheckoutFastForwardCommand(mainCheckout) {
155
+ export async function syncMainCheckout(mainCheckout, run) {
54
156
  const quoted = shellQuotePath(mainCheckout);
55
- // ponytail: guard with a `[ ... = main ]` test instead of switching branches — a
56
- // non-main checkout fails the && chain (warn-and-continue) rather than ff-ing the
57
- // wrong branch. No state change, no git switch.
58
- return `git -C ${quoted} fetch origin main && [ "$(git -C ${quoted} rev-parse --abbrev-ref HEAD)" = main ] && git -C ${quoted} merge --ff-only origin/main`;
157
+
158
+ const fetchResult = await runStep(run, `git -C ${quoted} fetch origin main`);
159
+ if (!fetchResult.ok) {
160
+ return { status: "skipped", reason: fetchResult.reason };
161
+ }
162
+
163
+ const refResult = await runStep(run, `git -C ${quoted} rev-parse --symbolic-full-name HEAD`);
164
+ if (!refResult.ok) {
165
+ return { status: "skipped", reason: refResult.reason };
166
+ }
167
+ const symbolicRef = refResult.stdout.trim();
168
+ const classification = classifyMainCheckoutRef(symbolicRef);
169
+ if (classification === "unreadable") {
170
+ return { status: "skipped", reason: `could not determine the current branch (got: ${JSON.stringify(symbolicRef)})` };
171
+ }
172
+
173
+ if (classification === "main") {
174
+ const mergeResult = await runStep(run, `git -C ${quoted} merge --ff-only origin/main`);
175
+ if (!mergeResult.ok) {
176
+ return { status: "skipped", reason: mergeResult.reason };
177
+ }
178
+ return { status: "fast_forwarded" };
179
+ }
180
+
181
+ let ref;
182
+ if (classification === "detached") {
183
+ const shortShaResult = await runStep(run, `git -C ${quoted} rev-parse --short HEAD`);
184
+ if (!shortShaResult.ok) {
185
+ return { status: "skipped", reason: shortShaResult.reason };
186
+ }
187
+ const shortSha = shortShaResult.stdout.trim();
188
+ if (!shortSha) {
189
+ return { status: "skipped", reason: "could not resolve a short SHA for the detached HEAD" };
190
+ }
191
+ ref = `detached@${shortSha}`;
192
+ } else {
193
+ ref = symbolicRef.slice("refs/heads/".length);
194
+ }
195
+
196
+ const behindResult = await runStep(run, `git -C ${quoted} rev-list --count HEAD..origin/main`);
197
+ if (!behindResult.ok) {
198
+ return { status: "skipped", reason: behindResult.reason };
199
+ }
200
+ const behindStdout = behindResult.stdout.trim();
201
+ if (!/^\d+$/.test(behindStdout)) {
202
+ return { status: "skipped", reason: `could not read the behind count (got: ${JSON.stringify(behindStdout)})` };
203
+ }
204
+ const behindCount = Number.parseInt(behindStdout, 10);
205
+
206
+ const diagnostic = buildMainCheckoutNotOnMainDiagnostic({ mainCheckout, ref, behindCount });
207
+ if (!diagnostic) {
208
+ return { status: "skipped", reason: "incomplete main_checkout_not_on_main diagnostic" };
209
+ }
210
+ return { status: "not_on_main", diagnostic };
59
211
  }
60
212
 
61
213
  /**
@@ -11,13 +11,16 @@
11
11
  *
12
12
  * It reuses, never re-derives, the existing precondition set:
13
13
  * `resolveSizeBudgetHumanApprovalRequired` (size-budget-merge-gate),
14
- * `findBlockingTitleMarkers` (pr-title-markers), and the detect-checkpoint-evidence
14
+ * `findBlockingTitleMarkers` (pr-title-markers), the detect-checkpoint-evidence
15
15
  * `preMergeGateCheck` bundle (draft/pre-approval verdicts, threads, runner lock,
16
- * fan-out provenance). This module adds ONLY the human-approver identity, the
17
- * merge-class split, and the aggregate naming.
16
+ * fan-out provenance), and `classifyCopilotReviewBodyDisposition` (copilot-helpers,
17
+ * the same current-head Copilot disposition detection the loop's
18
+ * `copilotBodyFeedbackUnresolved` reads). This module adds ONLY the
19
+ * human-approver identity, the merge-class split, the Copilot-convergence
20
+ * precondition, and the aggregate naming.
18
21
  */
19
22
 
20
- import { isCopilotLogin } from "../github/copilot-helpers.mjs";
23
+ import { isCopilotLogin, classifyCopilotReviewBodyDisposition, COPILOT_DISPOSITION, SUBMITTED_REVIEW_STATES, extractReviewCommitSha } from "../github/copilot-helpers.mjs";
21
24
  import { findBlockingTitleMarkers } from "./pr-title-markers.mjs";
22
25
  import { resolveSizeBudgetHumanApprovalRequired } from "./size-budget-merge-gate.mjs";
23
26
  import { deriveLoopCiStatusFromRollup } from "./copilot-ci-status.mjs";
@@ -81,6 +84,15 @@ function reviewCommit(entry) {
81
84
  return null;
82
85
  }
83
86
 
87
+ // Copilot-only login extraction for evaluateCopilotConvergence: accepts the
88
+ // `gh pr view` GraphQL shape (author.login) in addition to the REST shape
89
+ // (user.login | login) reviewLogin reads. Scoped to the Copilot-convergence
90
+ // eval so verifyFreshHumanApproval's human-approval matching is unchanged.
91
+ function copilotConvergenceReviewLogin(entry) {
92
+ if (typeof entry?.author?.login === "string" && entry.author.login.length > 0) return entry.author.login;
93
+ return reviewLogin(entry);
94
+ }
95
+
84
96
  /**
85
97
  * Verify a fresh, agent-unforgeable, head-pinned human approval by `approvedBy`.
86
98
  *
@@ -150,6 +162,147 @@ export function verifyFreshHumanApproval({ approvedBy, currentHeadSha, reviews =
150
162
  };
151
163
  }
152
164
 
165
+ export const COPILOT_CONVERGENCE_STATE = Object.freeze({
166
+ CURRENT_HEAD_CLEAN: "current_head_clean",
167
+ CURRENT_HEAD_FINDINGS: "current_head_findings",
168
+ NO_CURRENT_HEAD_REVIEW: "no_current_head_review",
169
+ });
170
+
171
+ /** ADR 0012 end states that may satisfy convergence without a current-head review. */
172
+ export const COPILOT_ABSENT_REVIEW_DISPOSITION = Object.freeze({
173
+ ROUND_CAP_CLEAN_FALLBACK: "round_cap_clean_fallback",
174
+ DOCS_ONLY_SUPPRESSION: "docs_only_suppression",
175
+ COPILOT_GATE_DISABLED: "copilot_gate_disabled",
176
+ });
177
+ const SANCTIONED_ABSENT_REVIEW_DISPOSITIONS = new Set(Object.values(COPILOT_ABSENT_REVIEW_DISPOSITION));
178
+
179
+ /**
180
+ * Shared Copilot-convergence evaluation for pre-approval entry and merge.
181
+ * Both treat a thread-clean 🔵 as conductor-overridable; unresolved threads
182
+ * still block independently. Fail-closed.
183
+ *
184
+ * Only the LATEST Copilot review pinned to `currentHeadSha` is judged, so a
185
+ * stale non-approval at an earlier head never blocks and a later same-head 🟢
186
+ * clears an earlier same-head finding.
187
+ *
188
+ * Policy (operator-resolved during the v1.0.4 drain):
189
+ * 🟡 "Changes recommended" on the current head -> BLOCK (actionable; the review
190
+ * body is unresolved feedback even with zero inline threads — the exact
191
+ * body-only fail-open the loop detects and this precondition enforces at merge).
192
+ * 🔵 "Needs a closer look" -> conductor-OVERRIDABLE (soft), NOT blocked here.
193
+ * Unresolved threads still gate it (detect-checkpoint-evidence refuses any
194
+ * unresolved review thread), so a 🔵 merges only with zero unresolved
195
+ * threads — the conductor's override is choosing to run the merge on a
196
+ * thread-clean 🔵.
197
+ * unrecognized disposition -> BLOCK (fail closed on a Copilot format change).
198
+ * 🟢 clean / headerless -> PASS.
199
+ *
200
+ * Three distinct states (`state` on the result):
201
+ * current_head_clean a current-head review passes (🟢 / headerless / 🔵);
202
+ * current_head_findings a current-head review blocks (🟡 / unrecognized);
203
+ * no_current_head_review no current-head Copilot review exists (none yet, or
204
+ * every review is on an earlier head). This state
205
+ * passes ONLY through a sanctioned disposition recorded
206
+ * for the current head (`absentReviewDisposition`
207
+ * `{ kind, headSha }`, kind in
208
+ * COPILOT_ABSENT_REVIEW_DISPOSITION). Absence alone never
209
+ * converges, and a stale earlier-head review is never
210
+ * inherited as the current-head verdict.
211
+ *
212
+ * @returns {{ ok: boolean, state: string|null, disposition: string|null, reason: string|null }}
213
+ */
214
+ export function evaluateCopilotConvergence({ currentHeadSha = null, reviews = [], absentReviewDisposition = null } = {}) {
215
+ const head = typeof currentHeadSha === "string" ? currentHeadSha.trim() : "";
216
+ // Head unknown: no current-head review can be pinned. Fail closed (matches
217
+ // verifyFreshHumanApproval), so this precondition can never pass without a
218
+ // known head to pin the Copilot disposition to.
219
+ if (head.length === 0) return { ok: false, state: null, disposition: null, reason: "current head SHA is unknown; cannot pin a Copilot review to it" };
220
+
221
+ // Mirror summarizeCopilotReviews' current-head finding selection BYTE-FOR-BYTE
222
+ // so the merge gate and the loop can never diverge (they already share
223
+ // classifyCopilotReviewBodyDisposition at the detection layer). Use the SAME
224
+ // comparison the loop uses — the RAW submittedAt string (a non-string is null),
225
+ // compared with `>`/`===`, NOT a parsed timestamp: parsing would diverge on a
226
+ // malformed/mixed-offset submittedAt (an invalid-timestamp 🟡 that the loop
227
+ // keeps as latest could otherwise be superseded here — a fail-open). Consider
228
+ // only SUBMITTED current-head reviews, skip PENDING drafts (a PENDING never
229
+ // sets the finding), pick the latest by submittedAt string, and on an
230
+ // equal-string tie (or both-null) fold toward the most-blocking disposition so
231
+ // array order never silently drops a finding.
232
+ let latestDisposition = null;
233
+ let latestAt = null;
234
+ for (const entry of Array.isArray(reviews) ? reviews : []) {
235
+ // Shape-tolerant Copilot-login + commit extraction: the merge gate feeds
236
+ // REST-shaped reviews (user.login/commit_id) while the gate-ENTRY detector
237
+ // feeds `gh pr view` GraphQL-shaped reviews (author.login/commit.oid). Both
238
+ // call sites route through this one evaluateCopilotConvergence, so it must
239
+ // recognize BOTH shapes to produce ONE convergence verdict. Only Copilot
240
+ // reviews matter here, so broadening the login read to author.login cannot
241
+ // affect verifyFreshHumanApproval (which keeps its own REST-only
242
+ // reviewLogin/reviewCommit).
243
+ const login = copilotConvergenceReviewLogin(entry);
244
+ if (login === null || !isCopilotLogin(login)) continue;
245
+ if (extractReviewCommitSha(entry) !== head) continue; // only current-head reviews
246
+ const state = typeof entry?.state === "string" ? entry.state.toUpperCase() : "";
247
+ if (state === "PENDING" || !SUBMITTED_REVIEW_STATES.has(state)) continue; // PENDING/unknown never sets the finding
248
+ const disposition = classifyCopilotReviewBodyDisposition(state, entry?.body);
249
+ const submittedAt = typeof entry?.submittedAt === "string"
250
+ ? entry.submittedAt
251
+ : (typeof entry?.submitted_at === "string" ? entry.submitted_at : null);
252
+ if (submittedAt !== null && (latestAt === null || submittedAt > latestAt)) {
253
+ latestDisposition = disposition; // a lexicographically-later submittedAt supersedes (matches summarize)
254
+ latestAt = submittedAt;
255
+ } else if (submittedAt !== null && submittedAt === latestAt) {
256
+ latestDisposition = latestDisposition === null ? disposition : moreBlockingDisposition(latestDisposition, disposition);
257
+ } else if (submittedAt === null && latestAt === null) {
258
+ latestDisposition = latestDisposition === null ? disposition : moreBlockingDisposition(latestDisposition, disposition);
259
+ }
260
+ // a null submittedAt once a non-null latest exists is ignored (mirrors summarize)
261
+ }
262
+ if (latestDisposition === null) {
263
+ const absent = COPILOT_CONVERGENCE_STATE.NO_CURRENT_HEAD_REVIEW;
264
+ const kind = absentReviewDisposition?.kind;
265
+ if (SANCTIONED_ABSENT_REVIEW_DISPOSITIONS.has(kind) && absentReviewDisposition?.headSha === head) {
266
+ return { ok: true, state: absent, disposition: kind, reason: null };
267
+ }
268
+ return {
269
+ ok: false,
270
+ state: absent,
271
+ disposition: null,
272
+ reason: `no current-head Copilot review exists on head ${head}; convergence requires a clean current-head review or a sanctioned disposition recorded for this head (${[...SANCTIONED_ABSENT_REVIEW_DISPOSITIONS].join(", ")})`,
273
+ };
274
+ }
275
+
276
+ const findings = COPILOT_CONVERGENCE_STATE.CURRENT_HEAD_FINDINGS;
277
+ if (latestDisposition === COPILOT_DISPOSITION.CHANGES_RECOMMENDED) {
278
+ return { ok: false, state: findings, disposition: latestDisposition, reason: `current-head Copilot review is "Changes recommended" (🟡, actionable non-approval); converge to "Approval recommended" (🟢) or resolve the feedback before merge` };
279
+ }
280
+ if (latestDisposition === COPILOT_DISPOSITION.UNRECOGNIZED) {
281
+ return { ok: false, state: findings, disposition: latestDisposition, reason: `current-head Copilot review carries an unrecognized disposition header (fail closed); a recognized "Approval recommended" (🟢) is required` };
282
+ }
283
+ // CLEAN, NONE, and NEEDS_CLOSER_LOOK (🔵, conductor-overridable) pass.
284
+ return { ok: true, state: COPILOT_CONVERGENCE_STATE.CURRENT_HEAD_CLEAN, disposition: latestDisposition, reason: null };
285
+ }
286
+
287
+ // Disposition blocking precedence, most-blocking first. Used to fold an
288
+ // equal-timestamp same-head tie toward the most-blocking disposition so a tied
289
+ // 🟡/unrecognized is never silently dropped by a co-timestamped 🟢/🔵.
290
+ const COPILOT_DISPOSITION_BLOCKING_ORDER = [
291
+ COPILOT_DISPOSITION.CHANGES_RECOMMENDED,
292
+ COPILOT_DISPOSITION.UNRECOGNIZED,
293
+ COPILOT_DISPOSITION.NEEDS_CLOSER_LOOK,
294
+ COPILOT_DISPOSITION.CLEAN,
295
+ COPILOT_DISPOSITION.NONE,
296
+ ];
297
+ function moreBlockingDisposition(a, b) {
298
+ const ia = COPILOT_DISPOSITION_BLOCKING_ORDER.indexOf(a);
299
+ const ib = COPILOT_DISPOSITION_BLOCKING_ORDER.indexOf(b);
300
+ // A value absent from the order (defensive) sorts last.
301
+ const ra = ia === -1 ? COPILOT_DISPOSITION_BLOCKING_ORDER.length : ia;
302
+ const rb = ib === -1 ? COPILOT_DISPOSITION_BLOCKING_ORDER.length : ib;
303
+ return ra <= rb ? a : b;
304
+ }
305
+
153
306
  /**
154
307
  * Decide whether merge is authorized given the class, the standing
155
308
  * authorization signal, and any fresh per-merge approval.
@@ -227,6 +380,8 @@ export function evaluateMergePreconditions({
227
380
  comments = [],
228
381
  standingAuthorized = false,
229
382
  stableRelease = false,
383
+ copilotAbsentReviewDisposition = null,
384
+ copilotBodyDisposition = null,
230
385
  } = {}) {
231
386
  const failures = [];
232
387
 
@@ -273,11 +428,37 @@ export function evaluateMergePreconditions({
273
428
  failures.push({ precondition: "size_budget_human_approval", reason: "size-budget requires a human APPROVED review OR a head-pinned \"approve merge <headSha>\" operator comment, with zero unresolved CHANGES_REQUESTED, for this escalated/T1 PR" });
274
429
  }
275
430
 
431
+ // Copilot-convergence precondition: refuse a current-head Copilot non-approval
432
+ // body disposition, mirroring the loop's copilotBodyFeedbackUnresolved.
433
+ // A trusted copilot-body-disposition record resolved for the current head
434
+ // (`{ headSha, reviewId, ... }`) clears a current-head finding, as it does
435
+ // at gate entry.
436
+ const copilotConvergence = evaluateCopilotConvergence({ currentHeadSha, reviews, absentReviewDisposition: copilotAbsentReviewDisposition });
437
+ const head = typeof currentHeadSha === "string" ? currentHeadSha.trim().toLowerCase() : "";
438
+ const bodyCleared = copilotConvergence.state === COPILOT_CONVERGENCE_STATE.CURRENT_HEAD_FINDINGS
439
+ && head.length > 0
440
+ && typeof copilotBodyDisposition?.headSha === "string" && copilotBodyDisposition.headSha.toLowerCase() === head;
441
+ if (!copilotConvergence.ok && !bodyCleared) {
442
+ failures.push({ precondition: "copilot_convergence", reason: copilotConvergence.reason });
443
+ }
444
+
276
445
  const mergeClass = resolveMergeClass({ sizeOutcome, touchesT1, stableRelease });
277
446
  const decision = resolveMergeApprovalDecision({ mergeClass, standingAuthorized, freshApproval });
278
447
  if (!decision.authorized) {
279
448
  failures.push({ precondition: "merge_approval", reason: decision.reason });
280
449
  }
281
450
 
282
- return { ok: failures.length === 0, failures, mergeClass, approvalVia: decision.authorized ? decision.via : null };
451
+ return {
452
+ ok: failures.length === 0,
453
+ failures,
454
+ mergeClass,
455
+ approvalVia: decision.authorized ? decision.via : null,
456
+ // Audit trace: which convergence state applied and what settled it (the
457
+ // current-head review disposition, or the sanctioned disposition kind when
458
+ // no current-head review exists), so a merge on a conductor-overridable 🔵
459
+ // or without a current-head review is recorded, never invisible.
460
+ copilotConvergenceState: copilotConvergence.state,
461
+ copilotDisposition: copilotConvergence.disposition,
462
+ copilotBodyDisposition: bodyCleared ? copilotBodyDisposition : null,
463
+ };
283
464
  }