@dev-loops/core 1.0.2 → 1.0.4-pre.0

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.
@@ -0,0 +1,399 @@
1
+ /**
2
+ * Sanctioned merge-wrapper decision logic. Pure, no I/O.
3
+ *
4
+ * The CLI wrapper `scripts/github/merge-pr.mjs` gathers live GitHub facts
5
+ * (mergeable state, CI rollup, gate evidence, size-budget outcome, reviews,
6
+ * comments) and feeds them here. This module owns the FAIL-CLOSED decisions:
7
+ * - is `--human-approved-by` a real GitHub login;
8
+ * - which merge class the PR is in (drain vs escalated);
9
+ * - whether a fresh, agent-unforgeable, head-pinned human approval exists;
10
+ * - and the aggregate precondition verdict that names each failing precondition.
11
+ *
12
+ * It reuses, never re-derives, the existing precondition set:
13
+ * `resolveSizeBudgetHumanApprovalRequired` (size-budget-merge-gate),
14
+ * `findBlockingTitleMarkers` (pr-title-markers), the detect-checkpoint-evidence
15
+ * `preMergeGateCheck` bundle (draft/pre-approval verdicts, threads, runner lock,
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.
21
+ */
22
+
23
+ import { isCopilotLogin, classifyCopilotReviewBodyDisposition, COPILOT_DISPOSITION, SUBMITTED_REVIEW_STATES } from "../github/copilot-helpers.mjs";
24
+ import { findBlockingTitleMarkers } from "./pr-title-markers.mjs";
25
+ import { resolveSizeBudgetHumanApprovalRequired } from "./size-budget-merge-gate.mjs";
26
+ import { deriveLoopCiStatusFromRollup } from "./copilot-ci-status.mjs";
27
+
28
+ // A GitHub login: 1-39 chars, alphanumeric or single internal hyphens, never
29
+ // leading/trailing hyphen. This rejects a bare boolean, empty/whitespace, and
30
+ // free text, so `--human-approved-by` is a real login, not a boolean or free text.
31
+ const GITHUB_LOGIN_RE = /^[A-Za-z0-9](?:-?[A-Za-z0-9])*$/;
32
+
33
+ /** True when `login` is shaped like a real GitHub login (fails closed on non-string). */
34
+ export function isValidGithubLogin(login) {
35
+ return typeof login === "string" && login.length >= 1 && login.length <= 39 && GITHUB_LOGIN_RE.test(login);
36
+ }
37
+
38
+ export const MERGE_CLASS = Object.freeze({ DRAIN: "drain", ESCALATED: "escalated" });
39
+
40
+ /**
41
+ * Classify the merge. An `escalate`/`block` size outcome, a T1-touching diff,
42
+ * or an explicit stable-release merge is ESCALATED — a standing authorization
43
+ * never satisfies it; it needs a fresh per-merge operator approval. Everything
44
+ * else is a normal DRAIN merge.
45
+ */
46
+ export function resolveMergeClass({ sizeOutcome = null, touchesT1 = false, stableRelease = false } = {}) {
47
+ if (stableRelease === true) return MERGE_CLASS.ESCALATED;
48
+ if (sizeOutcome === "escalate" || sizeOutcome === "block") return MERGE_CLASS.ESCALATED;
49
+ if (touchesT1 === true) return MERGE_CLASS.ESCALATED;
50
+ return MERGE_CLASS.DRAIN;
51
+ }
52
+
53
+ function escapeRegex(value) {
54
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
55
+ }
56
+
57
+ function reviewLogin(entry) {
58
+ if (typeof entry?.user?.login === "string" && entry.user.login.length > 0) return entry.user.login;
59
+ if (typeof entry?.login === "string" && entry.login.length > 0) return entry.login;
60
+ return null;
61
+ }
62
+
63
+ // A non-human login: the Copilot reviewer/bot (bracket-free `Copilot`
64
+ // variants, caught by isCopilotLogin) or any GitHub App bot login, which
65
+ // GitHub renders with a `[bot]` suffix (e.g. `github-actions[bot]`). Such a
66
+ // login can never satisfy the human-approval requirement. (A `[bot]` login can
67
+ // never be the named `--human-approved-by <login>` either — isValidGithubLogin
68
+ // rejects the brackets — so this only ever fires on a review/comment AUTHOR.)
69
+ function isNonHumanLogin(login) {
70
+ return isCopilotLogin(login) || /\[bot\]$/i.test(login);
71
+ }
72
+
73
+ // A non-human review/comment author. Beyond the login-shape check, a GitHub Bot
74
+ // account carries `user.type === "Bot"` even when its login has no `[bot]`
75
+ // suffix — reject it by that authoritative type so a bracket-free bot login can
76
+ // never satisfy the named fresh approver.
77
+ function isNonHumanAuthor(entry, login) {
78
+ return isNonHumanLogin(login) || (typeof entry?.type === "string" && entry.type.toLowerCase() === "bot");
79
+ }
80
+
81
+ function reviewCommit(entry) {
82
+ if (typeof entry?.commit_id === "string" && entry.commit_id.length > 0) return entry.commit_id;
83
+ if (typeof entry?.commitId === "string" && entry.commitId.length > 0) return entry.commitId;
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * Verify a fresh, agent-unforgeable, head-pinned human approval by `approvedBy`.
89
+ *
90
+ * Two accepted records, in preference order:
91
+ * 1. a genuine `APPROVED` review by `approvedBy` whose `commit_id` equals the
92
+ * current head SHA — GitHub forbids approving your own PR, so an `APPROVED`
93
+ * review can only have come from a real human distinct from the author;
94
+ * 2. else a head-pinned operator comment marker `approve merge <headSha>`
95
+ * authored by `approvedBy` — the solo-operator path, since an author cannot
96
+ * leave an `APPROVED` review on their own agent-authored PR.
97
+ *
98
+ * FAILS CLOSED (returns `{ satisfied: false }`) when the approval is stale (on
99
+ * an earlier commit), agent/bot-authored (a Copilot-login review/comment never
100
+ * satisfies), from a login other than `approvedBy`, or absent. Because both
101
+ * records are pinned to the current head SHA, this re-gates on every head bump.
102
+ *
103
+ * @returns {{ satisfied: boolean, via: "approved_review"|"comment_marker"|null, reason: string|null }}
104
+ */
105
+ export function verifyFreshHumanApproval({ approvedBy, currentHeadSha, reviews = [], comments = [] } = {}) {
106
+ if (!isValidGithubLogin(approvedBy)) {
107
+ return { satisfied: false, via: null, reason: "approvedBy is not a valid GitHub login" };
108
+ }
109
+ if (typeof currentHeadSha !== "string" || currentHeadSha.trim().length === 0) {
110
+ return { satisfied: false, via: null, reason: "current head SHA is unknown" };
111
+ }
112
+ const head = currentHeadSha.trim();
113
+
114
+ // Reduce to each login's LATEST submitted review (reviews arrive oldest-first,
115
+ // so the last occurrence wins — matching resolveHumanReviewDecision). A login
116
+ // whose APPROVED review was later superseded by a COMMENTED / CHANGES_REQUESTED
117
+ // / DISMISSED review no longer satisfies: only their latest state counts.
118
+ const latestReviewByLogin = new Map();
119
+ for (const entry of Array.isArray(reviews) ? reviews : []) {
120
+ const login = reviewLogin(entry);
121
+ if (login === null) continue;
122
+ latestReviewByLogin.set(login, entry);
123
+ }
124
+ const approverReview = latestReviewByLogin.get(approvedBy);
125
+ if (
126
+ approverReview
127
+ && !isNonHumanAuthor(approverReview, approvedBy) // agent/bot review never satisfies
128
+ && (typeof approverReview.state === "string" ? approverReview.state : null) === "APPROVED"
129
+ && reviewCommit(approverReview) === head // head-pinned — a stale/earlier-commit approval never satisfies
130
+ ) {
131
+ return { satisfied: true, via: "approved_review", reason: null };
132
+ }
133
+
134
+ // The marker must OPEN its line (after only leading whitespace / list / quote
135
+ // markers) so a negating operator comment never reads as approval: an
136
+ // unanchored `approve merge <head>` would also match `disapprove merge <head>`
137
+ // and `not approve merge <head>` — a fail-open on the merge-authorization
138
+ // path. The trailing `(?:\b|$)` keeps `<head>abc` from matching `<head>`.
139
+ const markerRe = new RegExp(`^[ \\t>*-]*approve\\s+merge\\s+${escapeRegex(head)}(?:\\b|$)`, "im");
140
+ for (const entry of Array.isArray(comments) ? comments : []) {
141
+ const login = reviewLogin(entry);
142
+ const body = typeof entry?.body === "string" ? entry.body : "";
143
+ if (login === null || isNonHumanAuthor(entry, login)) continue; // agent/bot comment never satisfies
144
+ if (login !== approvedBy) continue; // wrong login
145
+ if (!markerRe.test(body)) continue; // not a head-pinned marker for this head
146
+ return { satisfied: true, via: "comment_marker", reason: null };
147
+ }
148
+
149
+ return {
150
+ satisfied: false,
151
+ via: null,
152
+ reason: `no fresh ${approvedBy} approval on head ${head} (need an APPROVED review or an "approve merge ${head}" operator comment)`,
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Copilot-convergence merge precondition. Wires the current-head Copilot
158
+ * body-disposition detection into the merge gate so the merge wrapper
159
+ * and the loop (`copilotBodyFeedbackUnresolved`) read the SAME classification
160
+ * (`classifyCopilotReviewBodyDisposition`). The classification is shared, so it
161
+ * cannot drift; the POLICY differs by design (the loop self-blocks on 🔵, this
162
+ * gate treats 🔵 as conductor-overridable). Fail-closed.
163
+ *
164
+ * Only the LATEST Copilot review pinned to `currentHeadSha` is judged, so a
165
+ * stale non-approval at an earlier head never blocks and a later same-head 🟢
166
+ * clears an earlier same-head finding.
167
+ *
168
+ * Policy (operator-resolved during the v1.0.4 drain):
169
+ * 🟡 "Changes recommended" on the current head -> BLOCK (actionable; the review
170
+ * body is unresolved feedback even with zero inline threads — the exact
171
+ * body-only fail-open the loop detects and this precondition enforces at merge).
172
+ * 🔵 "Needs a closer look" -> conductor-OVERRIDABLE (soft), NOT blocked here.
173
+ * Unresolved threads still gate it (detect-checkpoint-evidence refuses any
174
+ * unresolved review thread), so a 🔵 merges only with zero unresolved
175
+ * threads — the conductor's override is choosing to run the merge on a
176
+ * thread-clean 🔵.
177
+ * unrecognized disposition -> BLOCK (fail closed on a Copilot format change).
178
+ * 🟢 clean / no current-head Copilot review / stale earlier-head -> PASS.
179
+ *
180
+ * @returns {{ ok: boolean, disposition: string|null, reason: string|null }}
181
+ */
182
+ export function evaluateCopilotConvergence({ currentHeadSha = null, reviews = [] } = {}) {
183
+ const head = typeof currentHeadSha === "string" ? currentHeadSha.trim() : "";
184
+ // Head unknown: no current-head review can be pinned. Fail closed (matches
185
+ // verifyFreshHumanApproval), so this precondition can never pass without a
186
+ // known head to pin the Copilot disposition to.
187
+ if (head.length === 0) return { ok: false, disposition: null, reason: "current head SHA is unknown; cannot pin a Copilot review to it" };
188
+
189
+ // Mirror summarizeCopilotReviews' current-head finding selection BYTE-FOR-BYTE
190
+ // so the merge gate and the loop can never diverge (they already share
191
+ // classifyCopilotReviewBodyDisposition at the detection layer). Use the SAME
192
+ // comparison the loop uses — the RAW submittedAt string (a non-string is null),
193
+ // compared with `>`/`===`, NOT a parsed timestamp: parsing would diverge on a
194
+ // malformed/mixed-offset submittedAt (an invalid-timestamp 🟡 that the loop
195
+ // keeps as latest could otherwise be superseded here — a fail-open). Consider
196
+ // only SUBMITTED current-head reviews, skip PENDING drafts (a PENDING never
197
+ // sets the finding), pick the latest by submittedAt string, and on an
198
+ // equal-string tie (or both-null) fold toward the most-blocking disposition so
199
+ // array order never silently drops a finding.
200
+ let latestDisposition = null;
201
+ let latestAt = null;
202
+ for (const entry of Array.isArray(reviews) ? reviews : []) {
203
+ const login = reviewLogin(entry);
204
+ if (login === null || !isCopilotLogin(login)) continue;
205
+ if (reviewCommit(entry) !== head) continue; // only current-head reviews
206
+ const state = typeof entry?.state === "string" ? entry.state.toUpperCase() : "";
207
+ if (state === "PENDING" || !SUBMITTED_REVIEW_STATES.has(state)) continue; // PENDING/unknown never sets the finding
208
+ const disposition = classifyCopilotReviewBodyDisposition(state, entry?.body);
209
+ const submittedAt = typeof entry?.submittedAt === "string"
210
+ ? entry.submittedAt
211
+ : (typeof entry?.submitted_at === "string" ? entry.submitted_at : null);
212
+ if (submittedAt !== null && (latestAt === null || submittedAt > latestAt)) {
213
+ latestDisposition = disposition; // a lexicographically-later submittedAt supersedes (matches summarize)
214
+ latestAt = submittedAt;
215
+ } else if (submittedAt !== null && submittedAt === latestAt) {
216
+ latestDisposition = latestDisposition === null ? disposition : moreBlockingDisposition(latestDisposition, disposition);
217
+ } else if (submittedAt === null && latestAt === null) {
218
+ latestDisposition = latestDisposition === null ? disposition : moreBlockingDisposition(latestDisposition, disposition);
219
+ }
220
+ // a null submittedAt once a non-null latest exists is ignored (mirrors summarize)
221
+ }
222
+ if (latestDisposition === null) return { ok: true, disposition: null, reason: null };
223
+
224
+ if (latestDisposition === COPILOT_DISPOSITION.CHANGES_RECOMMENDED) {
225
+ return { ok: false, disposition: latestDisposition, reason: `current-head Copilot review is "Changes recommended" (🟡, actionable non-approval); converge to "Approval recommended" (🟢) or resolve the feedback before merge` };
226
+ }
227
+ if (latestDisposition === COPILOT_DISPOSITION.UNRECOGNIZED) {
228
+ return { ok: false, disposition: latestDisposition, reason: `current-head Copilot review carries an unrecognized disposition header (fail closed); a recognized "Approval recommended" (🟢) is required` };
229
+ }
230
+ // CLEAN, NONE, and NEEDS_CLOSER_LOOK (🔵, conductor-overridable) pass.
231
+ return { ok: true, disposition: latestDisposition, reason: null };
232
+ }
233
+
234
+ // Disposition blocking precedence, most-blocking first. Used to fold an
235
+ // equal-timestamp same-head tie toward the most-blocking disposition so a tied
236
+ // 🟡/unrecognized is never silently dropped by a co-timestamped 🟢/🔵.
237
+ const COPILOT_DISPOSITION_BLOCKING_ORDER = [
238
+ COPILOT_DISPOSITION.CHANGES_RECOMMENDED,
239
+ COPILOT_DISPOSITION.UNRECOGNIZED,
240
+ COPILOT_DISPOSITION.NEEDS_CLOSER_LOOK,
241
+ COPILOT_DISPOSITION.CLEAN,
242
+ COPILOT_DISPOSITION.NONE,
243
+ ];
244
+ function moreBlockingDisposition(a, b) {
245
+ const ia = COPILOT_DISPOSITION_BLOCKING_ORDER.indexOf(a);
246
+ const ib = COPILOT_DISPOSITION_BLOCKING_ORDER.indexOf(b);
247
+ // A value absent from the order (defensive) sorts last.
248
+ const ra = ia === -1 ? COPILOT_DISPOSITION_BLOCKING_ORDER.length : ia;
249
+ const rb = ib === -1 ? COPILOT_DISPOSITION_BLOCKING_ORDER.length : ib;
250
+ return ra <= rb ? a : b;
251
+ }
252
+
253
+ /**
254
+ * Decide whether merge is authorized given the class, the standing
255
+ * authorization signal, and any fresh per-merge approval.
256
+ *
257
+ * DRAIN: satisfied by a recorded standing authorization OR a fresh approval.
258
+ * ESCALATED: a standing authorization does NOT satisfy it — a fresh per-merge
259
+ * operator approval is required.
260
+ *
261
+ * @returns {{ authorized: boolean, via: string|null, reason: string|null }}
262
+ */
263
+ export function resolveMergeApprovalDecision({ mergeClass, standingAuthorized = false, freshApproval = null } = {}) {
264
+ const fresh = freshApproval != null && freshApproval.satisfied === true;
265
+ if (mergeClass === MERGE_CLASS.ESCALATED) {
266
+ if (fresh) return { authorized: true, via: freshApproval.via, reason: null };
267
+ return {
268
+ authorized: false,
269
+ via: null,
270
+ reason: `escalated/stable-release merge requires a fresh per-merge operator approval; a standing authorization does not satisfy it${freshApproval?.reason ? ` (${freshApproval.reason})` : ""}`,
271
+ };
272
+ }
273
+ if (standingAuthorized === true) return { authorized: true, via: "standing_authorization", reason: null };
274
+ if (fresh) return { authorized: true, via: freshApproval.via, reason: null };
275
+ return {
276
+ authorized: false,
277
+ via: null,
278
+ reason: `drain merge requires a recorded standing authorization or a fresh operator approval${freshApproval?.reason ? ` (${freshApproval.reason})` : ""}`,
279
+ };
280
+ }
281
+
282
+ /**
283
+ * Resolve CI-green from a `gh pr view --json statusCheckRollup` payload.
284
+ *
285
+ * Delegates to the canonical loop-safe normalizer `deriveLoopCiStatusFromRollup`,
286
+ * which EXCLUDES the loop-derived `gate-evidence` / `gate-evidence-runner` checks
287
+ * (detect-checkpoint-evidence validates those separately, so a cancelled/failing
288
+ * derived check must not block a merge whose real CI is green) and treats a
289
+ * completed-but-no-conclusion or otherwise-unreadable entry as non-success.
290
+ * Fails closed: only a real `success` is green; pending/failure/unavailable are
291
+ * not. An empty or no-CI rollup normalizes to `none`, which is NOT green (a PR
292
+ * with no visible CI does not auto-satisfy this precondition).
293
+ */
294
+ export function resolveCiGreenFromRollup(rollup) {
295
+ if (!Array.isArray(rollup)) return { green: false, reason: "CI status rollup unavailable" };
296
+ const { status, excludedFailureDetails } = deriveLoopCiStatusFromRollup(rollup);
297
+ if (status === "success") return { green: true, reason: null };
298
+ return {
299
+ green: false,
300
+ reason: `CI is not green on the current head (status=${status})`,
301
+ ...(Array.isArray(excludedFailureDetails) && excludedFailureDetails.length > 0 ? { excludedFailureDetails } : {}),
302
+ };
303
+ }
304
+
305
+ /**
306
+ * Aggregate every merge precondition into one fail-closed verdict, naming the
307
+ * specific failing precondition(s). The CLI resolves the live facts and passes
308
+ * them in; this stays pure so each branch is unit-testable.
309
+ *
310
+ * @returns {{ ok: boolean, failures: Array<{ precondition: string, reason: string }>, mergeClass: string, approvalVia: string|null }}
311
+ */
312
+ export function evaluateMergePreconditions({
313
+ humanApprovedBy,
314
+ mergeable = null,
315
+ mergeStateStatus = null,
316
+ ciGreen = null,
317
+ title = null,
318
+ gateEvidence = null,
319
+ sizeOutcome = null,
320
+ // Default null (not false): a missing/absent T1 signal must reach
321
+ // resolveSizeBudgetHumanApprovalRequired as a non-boolean so it fails closed,
322
+ // rather than being coerced to "T1 untouched".
323
+ touchesT1 = null,
324
+ unresolvedChangesRequestedCount = null,
325
+ currentHeadSha = null,
326
+ reviews = [],
327
+ comments = [],
328
+ standingAuthorized = false,
329
+ stableRelease = false,
330
+ } = {}) {
331
+ const failures = [];
332
+
333
+ if (!isValidGithubLogin(humanApprovedBy)) {
334
+ failures.push({ precondition: "human_approver", reason: "--human-approved-by must be a real GitHub login (not empty, a boolean, or free text)" });
335
+ }
336
+
337
+ if (mergeable !== "MERGEABLE" || (typeof mergeStateStatus === "string" && ["DIRTY", "BEHIND", "UNKNOWN"].includes(mergeStateStatus.toUpperCase()))) {
338
+ failures.push({ precondition: "mergeable", reason: `PR is not conflict-free with base (mergeable=${mergeable ?? "unknown"}, mergeStateStatus=${mergeStateStatus ?? "unknown"}); expected mergeable=MERGEABLE` });
339
+ }
340
+
341
+ // Fail closed on anything but an explicit { green: true } — a `false`, null, or
342
+ // malformed ciGreen must NOT slip past this fail-closed aggregate.
343
+ if (!ciGreen || ciGreen.green !== true) {
344
+ failures.push({ precondition: "ci_green", reason: (ciGreen && ciGreen.reason) ? ciGreen.reason : "CI status could not be resolved for the current head" });
345
+ }
346
+
347
+ // findBlockingTitleMarkers returns [] for a non-string title, so an absent or
348
+ // malformed title payload would silently pass this fail-closed gate — refuse it.
349
+ if (typeof title !== "string" || title.trim().length === 0) {
350
+ failures.push({ precondition: "title_markers", reason: "PR title is missing or unreadable; cannot verify it is free of merge-blocking markers" });
351
+ } else {
352
+ const titleMarkers = findBlockingTitleMarkers(title);
353
+ if (titleMarkers.length > 0) {
354
+ failures.push({ precondition: "title_markers", reason: `PR title carries merge-blocking marker(s): ${titleMarkers.join(", ")}` });
355
+ }
356
+ }
357
+
358
+ if (!gateEvidence || gateEvidence.ok !== true) {
359
+ const reason = gateEvidence && Array.isArray(gateEvidence.failures) && gateEvidence.failures.length > 0
360
+ ? gateEvidence.failures.join("; ")
361
+ : "draft_gate / current-head pre_approval_gate evidence is missing or unverified";
362
+ failures.push({ precondition: "gate_evidence", reason });
363
+ }
364
+
365
+ // Computed once, ahead of the size gate, so both preconditions draw "valid
366
+ // human approval" from the one shared resolver instead of two divergent
367
+ // checks (verifyFreshHumanApproval already owns the comment token, head
368
+ // pinning, and bot exclusion; the size gate no longer re-derives it from
369
+ // reviewDecision alone).
370
+ const freshApproval = verifyFreshHumanApproval({ approvedBy: humanApprovedBy, currentHeadSha, reviews, comments });
371
+
372
+ if (resolveSizeBudgetHumanApprovalRequired({ sizeOutcome, touchesT1, humanApprovalSatisfied: freshApproval.satisfied, unresolvedChangesRequestedCount }) === true) {
373
+ 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" });
374
+ }
375
+
376
+ // Copilot-convergence precondition: refuse a current-head Copilot non-approval
377
+ // body disposition, mirroring the loop's copilotBodyFeedbackUnresolved.
378
+ const copilotConvergence = evaluateCopilotConvergence({ currentHeadSha, reviews });
379
+ if (!copilotConvergence.ok) {
380
+ failures.push({ precondition: "copilot_convergence", reason: copilotConvergence.reason });
381
+ }
382
+
383
+ const mergeClass = resolveMergeClass({ sizeOutcome, touchesT1, stableRelease });
384
+ const decision = resolveMergeApprovalDecision({ mergeClass, standingAuthorized, freshApproval });
385
+ if (!decision.authorized) {
386
+ failures.push({ precondition: "merge_approval", reason: decision.reason });
387
+ }
388
+
389
+ return {
390
+ ok: failures.length === 0,
391
+ failures,
392
+ mergeClass,
393
+ approvalVia: decision.authorized ? decision.via : null,
394
+ // Audit trace: the current-head Copilot disposition this verdict saw, so a
395
+ // merge that ran on a conductor-overridable 🔵 (or any disposition) is
396
+ // recorded on the machine-readable result rather than being invisible.
397
+ copilotDisposition: copilotConvergence.disposition,
398
+ };
399
+ }
@@ -8,6 +8,7 @@ import {
8
8
  MISSING_AC_DOD_MATRIX_FINDING,
9
9
  MISSING_EXPLICIT_NON_GOALS_FINDING,
10
10
  } from "./issue-refinement-artifact.mjs";
11
+ import { COMPLETE_FIXER_DISPOSITION_ACTION, FIXER_DISPOSITION_FORBIDDEN_ACTIONS } from "./fixer-disposition.mjs";
11
12
 
12
13
  export const PR_CHECKPOINT = Object.freeze({
13
14
  DRAFT_REVIEW: "draft_review",
@@ -70,6 +71,11 @@ export const PR_CHECKPOINT_ACTION = Object.freeze({
70
71
  REPORT_DONE: "report_done",
71
72
  RUN_UI_E2E_SUITE: "run_ui_e2e_suite",
72
73
  RECORD_DESIGNER_REVIEW: "record_designer_review",
74
+ // GATE-EXEC-FIXER-DISPOSITION-BOUNDARY: the only legal next action while a
75
+ // fixer's claimed-tackled threads have incomplete disposition. Value is the
76
+ // shared literal fixer-disposition.mjs's pure evaluator also returns as
77
+ // `nextAction` (asserted equal by test — see fixer-disposition.test.mjs).
78
+ COMPLETE_FIXER_DISPOSITION: COMPLETE_FIXER_DISPOSITION_ACTION,
73
79
  });
74
80
 
75
81
  function normalizeGateComment(summary = null) {
@@ -586,7 +592,59 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
586
592
  const copilotReviewRequestStatus = typeof input.copilotReviewRequestStatus === "string"
587
593
  ? input.copilotReviewRequestStatus.trim().toLowerCase()
588
594
  : "none";
589
- if (copilotReviewRequestStatus !== "requested" && copilotReviewRequestStatus !== "already-requested") {
595
+ const sameHeadCleanConverged = input.sameHeadCleanConverged === true;
596
+ const copilotReviewRoundCount = normalizeNonNegativeInteger(input.copilotReviewRoundCount);
597
+ const copilotReviewOnCurrentHead = input.copilotReviewOnCurrentHead === true;
598
+ const outstandingRequest = copilotReviewRequestStatus === "requested"
599
+ || copilotReviewRequestStatus === "already-requested";
600
+ const roundCapReached = isCopilotRoundCapReached({
601
+ copilotReviewRoundCount: input.copilotReviewRoundCount,
602
+ maxCopilotRounds: input.maxCopilotRounds,
603
+ });
604
+ // Absent / never-driven: Copilot review is enabled (maxCopilotRounds
605
+ // > 0, not internal_only) but no round was requested or received for the
606
+ // CURRENT head, so a clean pre_approval_gate verdict would rest on nothing the
607
+ // loop actually drove on the head under review. The reconciled status alone is
608
+ // ambiguous: the reconciler (resolveCopilotReviewRequestStatus) folds a clean
609
+ // same-head submitted review into "none" too. Three facts each prove this is
610
+ // not the never-driven case and exempt this branch:
611
+ // - sameHeadCleanConverged: a clean same-head submitted Copilot review
612
+ // exists on THIS head (including GitHub's incidental auto-review) — the
613
+ // no-redundant-re-request case;
614
+ // - copilotReviewOnCurrentHead: a submitted Copilot review exists on THIS
615
+ // head (a settled current-head round; at a grant boundary its threads are
616
+ // already resolved). This is keyed on CURRENT-HEAD evidence, never a raw
617
+ // across-PR round count: copilotReviewRoundCount counts reviews on ANY
618
+ // head, so a PR with prior-head rounds and a NEW current head that carries
619
+ // no review (interpreter state low_signal_converged with
620
+ // copilotReviewOnCurrentHead false) must still fail closed;
621
+ // - roundCapReached: at/past the cap no further Copilot round can be driven,
622
+ // so requiring a current-head review is impossible to satisfy — the
623
+ // pre_approval_gate reviews the post-cap head. The core only reaches a
624
+ // grant boundary here after gating CI/threads, and a significant
625
+ // post-convergence change routes to a rerequest (a new cycle) before this
626
+ // guard runs, so this exemption cannot mask a genuinely-unreviewed change;
627
+ // - postConvergenceReviewSuppressed: an operator verified (via
628
+ // withdraw-copilot-review-request) that the current-head delta since
629
+ // Copilot's last submitted review is a pure doc/prose bump, so the prior
630
+ // converged review stands for this head — the core grants pre_approval on
631
+ // the same basis (never derived here from other snapshot facts).
632
+ // Fail closed on ANY non-outstanding status, not only the literal "none":
633
+ // this is the independent gate-ENTRY re-check, so it must not trust the
634
+ // caller's status string. A non-canonical/unknown value ("", "unavailable",
635
+ // "failed", a typo) with a grant-y lifecycleState and no current-head review
636
+ // fails closed rather than slipping through the "none"-only default. Only a
637
+ // driven current-head review (or the impossible-further-round cap state, or an
638
+ // operator-verified pure-doc-bump suppression) exempts, so an agent that skips
639
+ // the explicit Copilot round cannot reach a clean pre_approval verdict via any
640
+ // grant-y lifecycleState (e.g. a stale/racy low_signal_converged label, or one
641
+ // carried by prior-head rounds).
642
+ const absentNeverDriven = !outstandingRequest
643
+ && !copilotReviewOnCurrentHead
644
+ && !sameHeadCleanConverged
645
+ && !roundCapReached
646
+ && input.postConvergenceReviewSuppressed !== true;
647
+ if (!outstandingRequest && !absentNeverDriven) {
590
648
  return null;
591
649
  }
592
650
  // Round-cap exemption (mirrors shouldGuardCopilotReviewRequest):
@@ -597,10 +655,6 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
597
655
  // and the head is clean — either sameHeadCleanConverged or the interpreter's
598
656
  // round_cap_clean_fallback state — the pre_approval_gate proceeds unless
599
657
  // significant post-convergence changes require a new review cycle.
600
- const roundCapReached = isCopilotRoundCapReached({
601
- copilotReviewRoundCount: input.copilotReviewRoundCount,
602
- maxCopilotRounds: input.maxCopilotRounds,
603
- });
604
658
  const lifecycleState = typeof input.lifecycleState === "string" ? input.lifecycleState.trim().toLowerCase() : "";
605
659
  // Also exempt the evaluator's own ROUND_CAP_REACHED grant shape:
606
660
  // without this, this guard would rewrite that grant back to
@@ -618,7 +672,15 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
618
672
 
619
673
  const allowedNextActions = [];
620
674
  const forbiddenActions = [];
621
- pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.WAIT_FOR_COPILOT_REVIEW]);
675
+ // Two shapes fail closed here. An OUTSTANDING request (requested/
676
+ // already-requested) waits for the current-head review to settle. The
677
+ // ABSENT / never-driven case has nothing to wait for — no request is
678
+ // in flight — so the loop must first REQUEST a Copilot review, mirroring the
679
+ // detector-side shouldGuardCopilotReviewRequest override.
680
+ const nextAction = absentNeverDriven
681
+ ? PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW
682
+ : PR_CHECKPOINT_ACTION.WAIT_FOR_COPILOT_REVIEW;
683
+ pushUnique(allowedNextActions, [nextAction]);
622
684
  // Full postDraftForbidden set (matching the canonical WAITING_FOR_COPILOT_REVIEW
623
685
  // result this guard synthesizes) plus the final-approval actions the replaced
624
686
  // boundary result also forbade — dropping RUN_DRAFT_GATE/MARK_READY_FOR_REVIEW
@@ -636,18 +698,24 @@ function applyUnsettledCopilotReviewEntryGuard(input, result) {
636
698
  repo: input.repo ?? null,
637
699
  pr: Number.isInteger(input.pr) ? input.pr : null,
638
700
  currentHeadSha: result.currentHeadSha ?? null,
639
- lifecycleState: STATE.WAITING_FOR_COPILOT_REVIEW,
640
- loopDisposition: DISPOSITION.PENDING,
701
+ lifecycleState: absentNeverDriven ? STATE.PR_READY_NO_FEEDBACK : STATE.WAITING_FOR_COPILOT_REVIEW,
702
+ loopDisposition: absentNeverDriven ? DISPOSITION.ACTION_REQUIRED : DISPOSITION.PENDING,
641
703
  gateBoundary: PR_CHECKPOINT.POST_DRAFT_EXTERNAL_REVIEW,
642
704
  draftGateAlreadySatisfied: result.draftGateAlreadySatisfied === true,
643
705
  draftGate: result.draftGate,
644
706
  preApprovalGate: result.preApprovalGate,
645
707
  allowedNextActions,
646
708
  forbiddenActions,
647
- nextAction: PR_CHECKPOINT_ACTION.WAIT_FOR_COPILOT_REVIEW,
648
- reason: "A Copilot review request is still outstanding on the current head (independent gate-entry "
649
- + "re-check, issue #1190) — pre_approval_gate/final-approval entry is refused until the current-head "
650
- + "review settles, even though the caller-reported convergence signal claims otherwise.",
709
+ nextAction,
710
+ reason: absentNeverDriven
711
+ ? "Copilot review is enabled for this repo (maxCopilotRounds > 0) but no Copilot review round has been "
712
+ + "requested or received for the current head (independent gate-entry re-check, issue #2146) — a clean "
713
+ + "pre_approval_gate/final-approval verdict requires a Copilot round the loop actually drove and awaited, "
714
+ + "so request Copilot review first. A clean same-head submitted Copilot review (including GitHub's "
715
+ + "auto-review) would satisfy this; an absent/never-driven round is not settled convergence."
716
+ : "A Copilot review request is still outstanding on the current head (independent gate-entry "
717
+ + "re-check, issue #1190) — pre_approval_gate/final-approval entry is refused until the current-head "
718
+ + "review settles, even though the caller-reported convergence signal claims otherwise.",
651
719
  mergeStateStatus: result.mergeStateStatus ?? null,
652
720
  conflictFiles: result.conflictFiles ?? [],
653
721
  refinementArtifact: result.refinementArtifact ?? null,
@@ -908,6 +976,49 @@ function evaluatePrGateCoordinationCore(input = {}) {
908
976
  });
909
977
  }
910
978
 
979
+ // GATE-EXEC-FIXER-DISPOSITION-BOUNDARY (skills/docs/gate-review-sub-loop-contract.md):
980
+ // a caller-supplied fixerDisposition input records whether every thread a
981
+ // fixer claims to have tackled since the last push is fully disposed
982
+ // (commit contained, replied with that commit's evidence, resolved, and
983
+ // re-verified live — see fixer-disposition.mjs's pure evaluator). Present
984
+ // and NOT complete fails this boundary CLOSED regardless of
985
+ // unresolvedThreadCount or lifecycleState — the failure this closes is a
986
+ // review round opening over a dirty surface even when the thread count
987
+ // itself reads clean (bogus/uncontained evidence), so it must run ahead of
988
+ // every lifecycle-state branch below, not be derived from one.
989
+ const fixerDisposition = input.fixerDisposition && typeof input.fixerDisposition === "object"
990
+ ? input.fixerDisposition
991
+ : null;
992
+ if (fixerDisposition && fixerDisposition.complete !== true) {
993
+ pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.COMPLETE_FIXER_DISPOSITION]);
994
+ pushUnique(forbiddenActions, FIXER_DISPOSITION_FORBIDDEN_ACTIONS);
995
+ const incompleteThreads = Array.isArray(fixerDisposition.incomplete) ? fixerDisposition.incomplete : [];
996
+ const reasonParts = incompleteThreads.map((entry) => (
997
+ `thread ${entry.threadId} (expected commit ${entry.expectedCommit ?? "unknown"}, failed step: ${entry.failedStep})`
998
+ ));
999
+ return buildResult({
1000
+ repo: input.repo ?? null,
1001
+ pr: Number.isInteger(input.pr) ? input.pr : null,
1002
+ currentHeadSha,
1003
+ lifecycleState: effectiveLifecycleState,
1004
+ loopDisposition: DISPOSITION.UNRESOLVED_FEEDBACK,
1005
+ gateBoundary: PR_CHECKPOINT.FEEDBACK_RESOLUTION,
1006
+ draftGateAlreadySatisfied,
1007
+ draftGate,
1008
+ preApprovalGate,
1009
+ allowedNextActions,
1010
+ forbiddenActions,
1011
+ nextAction: PR_CHECKPOINT_ACTION.COMPLETE_FIXER_DISPOSITION,
1012
+ reason: reasonParts.length > 0
1013
+ ? `GATE-EXEC-FIXER-DISPOSITION-BOUNDARY forbids every review/gate-dispatch action for ${incompleteThreads.length} tackled thread(s) with incomplete disposition: ${reasonParts.join("; ")}. The only legal next action is ${PR_CHECKPOINT_ACTION.COMPLETE_FIXER_DISPOSITION}.`
1014
+ : `GATE-EXEC-FIXER-DISPOSITION-BOUNDARY forbids every review/gate-dispatch action until fixer disposition is complete and re-verified. The only legal next action is ${PR_CHECKPOINT_ACTION.COMPLETE_FIXER_DISPOSITION}.`,
1015
+ mergeStateStatus,
1016
+ conflictFiles,
1017
+ refinementArtifact,
1018
+ copilotReviewRoundCount,
1019
+ });
1020
+ }
1021
+
911
1022
  // UI e2e auto-scoping precondition. Path-triggered + fail-closed:
912
1023
  // if the PR's changed files touch a rendered artifact (a deck under
913
1024
  // docs/articles|presentations, or the inspect-run viewer source), it MUST be
@@ -50,9 +50,11 @@ export const DEFAULT_STATE_LOGICAL_MAP = Object.freeze({
50
50
  issue_intake: LOGICAL_COLUMN.NEXT_UP,
51
51
  refinement: LOGICAL_COLUMN.NEXT_UP,
52
52
  no_pr: LOGICAL_COLUMN.NEXT_UP,
53
- pr_draft: LOGICAL_COLUMN.NEXT_UP,
54
53
 
55
54
  // In Progress — active implementation / review / feedback resolution
55
+ // A draft PR exists, so a runner owns the item; it is no longer pickable.
56
+ // This reconciles with the outer `implementation` lifecycle mapping.
57
+ pr_draft: LOGICAL_COLUMN.IN_PROGRESS,
56
58
  implementation: LOGICAL_COLUMN.IN_PROGRESS,
57
59
  // Tolerated alias for `implementation` (conceptual name);
58
60
  // the queue driver passes the real `implementation` lifecycle state.
@@ -125,8 +127,9 @@ export function deriveReconcileColumn(facts = {}) {
125
127
  // Merged PR (item is a PR, or issue's linked PR merged) => Done.
126
128
  if (prState === "MERGED") return LOGICAL_COLUMN.DONE;
127
129
  if (itemKind === "issue" && issueState === "CLOSED") return LOGICAL_COLUMN.DONE;
128
- // Open, ready (non-draft) PR => In Progress.
129
- if (prState === "OPEN" && prIsDraft === false) return LOGICAL_COLUMN.IN_PROGRESS;
130
+ // Any OPEN linked PR (draft or ready) => In Progress: a runner owns the item,
131
+ // so it must never be advertised in the pickup queue.
132
+ if (prState === "OPEN") return LOGICAL_COLUMN.IN_PROGRESS;
130
133
  // Otherwise leave the item untouched (Backlog / Next Up ordering preserved).
131
134
  return null;
132
135
  }