mandrel 2.35.0 → 2.36.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.
Files changed (24) hide show
  1. package/.agents/docs/agentrc-reference.json +3 -1
  2. package/.agents/docs/configuration.md +2 -0
  3. package/.agents/schemas/agentrc.schema.json +11 -0
  4. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  5. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  6. package/.agents/scripts/check-doc-links.js +23 -2
  7. package/.agents/scripts/git-cleanup.js +2 -0
  8. package/.agents/scripts/lib/config/ci.js +18 -0
  9. package/.agents/scripts/lib/config-settings-schema-delivery.js +13 -0
  10. package/.agents/scripts/lib/observability/source-classifier.js +0 -1
  11. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +22 -7
  12. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -14
  13. package/.agents/scripts/lib/orchestration/git-cleanup/phases/merged-tip.js +132 -0
  14. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +56 -11
  15. package/.agents/scripts/lib/orchestration/merge-block-class.js +10 -1
  16. package/.agents/scripts/lib/orchestration/merge-poll.js +164 -0
  17. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +145 -0
  18. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -5
  19. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +9 -1
  20. package/.agents/scripts/notify.js +4 -10
  21. package/.agents/workflows/audit-documentation.md +5 -6
  22. package/docs/CHANGELOG.md +18 -0
  23. package/package.json +3 -3
  24. package/.agents/scripts/generate-lifecycle-docs.js +0 -237
@@ -340,7 +340,9 @@
340
340
  "maxResumes": 3,
341
341
  "attachWindowMs": 1200000
342
342
  },
343
- "autoMerge": "trust-ci"
343
+ "autoMerge": "trust-ci",
344
+ "blockOnAdvisoryFailure": true,
345
+ "advisoryAllowlist": []
344
346
  },
345
347
  "routing": {
346
348
  "roleScopedAgents": true,
@@ -320,6 +320,8 @@ Everything `/deliver` and `single-story-close` consume: execution timeouts, work
320
320
  | `ci.watch.maxResumes` | No | `integer` | `3` | How many times the watcher may resume after a transient stall. 0 disables resuming. |
321
321
  | `ci.watch.attachWindowMs` | No | `integer` | `1200000` | Story #4890. How long (ms) the watch keeps re-resolving an EMPTY `gh pr checks --required` set before it stops waiting for a required context to attach. A ruleset attaches its contexts asynchronously and the arrival latency is set by the slowest one, so a required context that is an aggregator job gated on every other tier is the last to appear — measured at 16m52s on this repository. Default 1200000 (20 minutes). Raise it for a repository whose contexts arrive later still; exhausting the window is never reported as a red check (the watch exits 2, not-yet-started). |
322
322
  | `ci.autoMerge` | No | `"trust-ci"` \| `"strict"` | `"trust-ci"` | Story #4356 (Epic #4355). Merge posture. 'trust-ci' (default) merges once required checks pass; 'strict' additionally requires a clean review gate. |
323
+ | `ci.blockOnAdvisoryFailure` | No | `boolean` | `true` | Story #5096. When true (default), delivery refuses to arm — and disarms — GitHub native auto-merge while a non-required (advisory) check is genuinely red on the PR head and GitHub reports the PR mergeable anyway (mergeStateStatus=UNSTABLE). `--auto` waits on REQUIRED contexts only, so without this a red advisory quality gate merges unattended. Set false to restore the pre-#5096 behaviour verbatim. |
324
+ | `ci.advisoryAllowlist` | No | `array<string>` | `[]` | Story #5096. Check-run names exempt from blockOnAdvisoryFailure — a red run whose name matches exactly never blocks arming. Matching is exact; an unnamed run can never match and always blocks. |
323
325
  | `routing` | No | `object` | — | v2 delivery-spawn routing: role-scoped boot contexts and maker-checker sampling. The v1 singleDelivery epic-route kill-switch was removed in Stage 6. |
324
326
  | `routing.roleScopedAgents` | No | `boolean` | `true` | Epic #4478 (M7-B). Kill-switch for the role-scoped boot contexts. When true (default), a converted delivery spawn (`story-worker`, `acceptance-critic`) boots on its own `.claude/agents/<role>.md` system prompt instead of re-paying the full CLAUDE.md @-import closure. When false, every converted spawn falls back to `subagent_type: general-purpose` — the instant, code-rollback-free per-consumer revert, and the universal escape for hosts that ignore `.claude/agents/`. The fallback is the full-closure agent that ran before M7-B, so flipping it off never drops a gate. |
325
327
  | `routing.freshCriticSampleRate` | No | `number` | `0.2` | Epic #4478 (M7-B, Part 2). Maker-checker sampling floor. Under the standard profile, a change set touching no sensitive path routes its acceptance clusters down the contract-identical inline critic path, but this fraction of them is still forced through a fresh-context critic so a low derived level never means zero independent checking. Clamped to [0, 1]; 0 disables the floor, 1 forces every cluster fresh. Consumed by resolveCeremonyForRisk (lib/orchestration/ceremony-routing.js). |
@@ -1913,6 +1913,17 @@
1913
1913
  "enum": ["trust-ci", "strict"],
1914
1914
  "description": "Story #4356 (Epic #4355). Merge posture. 'trust-ci' (default) merges once required checks pass; 'strict' additionally requires a clean review gate.",
1915
1915
  "default": "trust-ci"
1916
+ },
1917
+ "blockOnAdvisoryFailure": {
1918
+ "type": "boolean",
1919
+ "description": "Story #5096. When true (default), delivery refuses to arm — and disarms — GitHub native auto-merge while a non-required (advisory) check is genuinely red on the PR head and GitHub reports the PR mergeable anyway (mergeStateStatus=UNSTABLE). `--auto` waits on REQUIRED contexts only, so without this a red advisory quality gate merges unattended. Set false to restore the pre-#5096 behaviour verbatim.",
1920
+ "default": true
1921
+ },
1922
+ "advisoryAllowlist": {
1923
+ "type": "array",
1924
+ "items": { "type": "string" },
1925
+ "description": "Story #5096. Check-run names exempt from blockOnAdvisoryFailure — a red run whose name matches exactly never blocks arming. Matching is exact; an unnamed run can never match and always blocks.",
1926
+ "default": []
1916
1927
  }
1917
1928
  },
1918
1929
  "additionalProperties": false
@@ -29,7 +29,8 @@
29
29
  "branch-protection-human-required",
30
30
  "arm-failure",
31
31
  "api-race-other",
32
- "predicate-refused"
32
+ "predicate-refused",
33
+ "advisory-gate-red"
33
34
  ]
34
35
  },
35
36
  "reason": { "type": "string", "minLength": 1 },
@@ -125,6 +125,7 @@
125
125
  "arm-failure",
126
126
  "api-race-other",
127
127
  "predicate-refused",
128
+ "advisory-gate-red",
128
129
  "merged-flip-failed"
129
130
  ]
130
131
  },
@@ -325,6 +325,22 @@ function stripAnchorAndQuery(target) {
325
325
  return t;
326
326
  }
327
327
 
328
+ // Percent-decode a link target's path portion (Story #5090). A correctly
329
+ // encoded Markdown destination escapes URL-reserved characters — a
330
+ // file-based router's `[token]` segment is written `%5Btoken%5D`, the form
331
+ // CommonMark renderers require — but the filesystem knows only the decoded
332
+ // name. The decode is one-way and total: a malformed escape (`%zz`) degrades
333
+ // to the raw string instead of throwing `URIError`, so an undecodable target
334
+ // is resolved exactly as it was before.
335
+ function decodeLinkPath(pathOnly) {
336
+ if (!pathOnly.includes('%')) return pathOnly;
337
+ try {
338
+ return decodeURIComponent(pathOnly);
339
+ } catch {
340
+ return pathOnly;
341
+ }
342
+ }
343
+
328
344
  // --- Slash-token extraction ------------------------------------------------
329
345
 
330
346
  // Tokens look like `/<lowercase-alphanum-with-hyphens>`. We exclude tokens
@@ -394,8 +410,13 @@ export function checkFile(absPath, repoRoot) {
394
410
  // 2. Relative-link resolution.
395
411
  for (const { target, line } of extractLinks(masked)) {
396
412
  if (isExternalOrInternalAnchor(target)) continue;
397
- const pathOnly = stripAnchorAndQuery(target);
398
- if (!pathOnly) continue; // pure anchor that survived earlier check
413
+ const rawPathOnly = stripAnchorAndQuery(target);
414
+ if (!rawPathOnly) continue; // pure anchor that survived earlier check
415
+ // Decode AFTER anchor/query stripping — so an escaped `%23` cannot
416
+ // collapse into an anchor delimiter and truncate the target — and BEFORE
417
+ // resolution, so the payload-boundary branch below reports the decoded
418
+ // path rather than the escaped one.
419
+ const pathOnly = decodeLinkPath(rawPathOnly);
399
420
  let resolved;
400
421
  if (pathOnly.startsWith('/')) {
401
422
  // Treat root-absolute paths as repo-root relative.
@@ -64,6 +64,7 @@ import {
64
64
  probeMergedPr,
65
65
  refExists,
66
66
  } from './lib/orchestration/git-cleanup/phases/git-probes.js';
67
+ import { probeAncestry } from './lib/orchestration/git-cleanup/phases/merged-tip.js';
67
68
  import { parseCleanupArgs } from './lib/orchestration/git-cleanup/phases/parse-args.js';
68
69
  import {
69
70
  executePrune,
@@ -110,6 +111,7 @@ export {
110
111
  planFastForward,
111
112
  planStashes,
112
113
  probeAllPrs,
114
+ probeAncestry,
113
115
  probeContentEquivalent,
114
116
  probeLatestPr,
115
117
  probeMergedPr,
@@ -5,6 +5,13 @@
5
5
  * (default `"trust-ci"`) selects the merge posture — `"trust-ci"` merges once
6
6
  * required checks pass, `"strict"` additionally requires a clean review gate.
7
7
  *
8
+ * Story #5096 added `blockOnAdvisoryFailure` (default `true`) and
9
+ * `advisoryAllowlist` (default `[]`). GitHub native auto-merge waits on
10
+ * REQUIRED contexts only, so a red ADVISORY gate would otherwise be merged
11
+ * straight past; these knobs own mandrel's side of that decision. Set
12
+ * `blockOnAdvisoryFailure: false` to restore the pre-#5096 behaviour verbatim,
13
+ * or list a job name in `advisoryAllowlist` to exempt just that one.
14
+ *
8
15
  * Retired (no production readers on v2 Story-only delivery): `earlyPr`
9
16
  * (Epic early-PR warmup) and `requireChecks` (AutomergePredicate escape hatch
10
17
  * whose listener was never landed).
@@ -12,6 +19,8 @@
12
19
 
13
20
  export const CI_DELIVERY_DEFAULTS = Object.freeze({
14
21
  autoMerge: 'trust-ci',
22
+ blockOnAdvisoryFailure: true,
23
+ advisoryAllowlist: Object.freeze([]),
15
24
  });
16
25
 
17
26
  /**
@@ -31,6 +40,15 @@ export function getCiDelivery(config) {
31
40
  ci.autoMerge === 'trust-ci' || ci.autoMerge === 'strict'
32
41
  ? ci.autoMerge
33
42
  : CI_DELIVERY_DEFAULTS.autoMerge,
43
+ blockOnAdvisoryFailure:
44
+ typeof ci.blockOnAdvisoryFailure === 'boolean'
45
+ ? ci.blockOnAdvisoryFailure
46
+ : CI_DELIVERY_DEFAULTS.blockOnAdvisoryFailure,
47
+ advisoryAllowlist: Array.isArray(ci.advisoryAllowlist)
48
+ ? ci.advisoryAllowlist.filter(
49
+ (entry) => typeof entry === 'string' && entry,
50
+ )
51
+ : [...CI_DELIVERY_DEFAULTS.advisoryAllowlist],
34
52
  watch:
35
53
  ci.watch && typeof ci.watch === 'object' ? { ...ci.watch } : undefined,
36
54
  };
@@ -389,6 +389,19 @@ const CI_DELIVERY_SCHEMA = {
389
389
  "Story #4356 (Epic #4355). Merge posture. 'trust-ci' (default) merges once required checks pass; 'strict' additionally requires a clean review gate.",
390
390
  default: CI_DELIVERY_DEFAULTS.autoMerge,
391
391
  },
392
+ blockOnAdvisoryFailure: {
393
+ type: 'boolean',
394
+ description:
395
+ 'Story #5096. When true (default), delivery refuses to arm — and disarms — GitHub native auto-merge while a non-required (advisory) check is genuinely red on the PR head and GitHub reports the PR mergeable anyway (mergeStateStatus=UNSTABLE). `--auto` waits on REQUIRED contexts only, so without this a red advisory quality gate merges unattended. Set false to restore the pre-#5096 behaviour verbatim.',
396
+ default: CI_DELIVERY_DEFAULTS.blockOnAdvisoryFailure,
397
+ },
398
+ advisoryAllowlist: {
399
+ type: 'array',
400
+ items: { type: 'string' },
401
+ description:
402
+ 'Story #5096. Check-run names exempt from blockOnAdvisoryFailure — a red run whose name matches exactly never blocks arming. Matching is exact; an unnamed run can never match and always blocks.',
403
+ default: [...CI_DELIVERY_DEFAULTS.advisoryAllowlist],
404
+ },
392
405
  },
393
406
  additionalProperties: false,
394
407
  };
@@ -113,7 +113,6 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
113
113
  'evidence-gate.js',
114
114
  'generate-config-docs.js',
115
115
  'generate-lens-checklists.js',
116
- 'generate-lifecycle-docs.js',
117
116
  'generate-skills-index.js',
118
117
  'generate-workflows-doc.js',
119
118
  'git-cleanup.js',
@@ -37,15 +37,19 @@ import {
37
37
  removeWorktree,
38
38
  worktreesByBranch,
39
39
  } from './git-probes.js';
40
+ import { probeAncestry } from './merged-tip.js';
40
41
  import { parsePrunedRefs } from './prune.js';
41
42
 
42
43
  const TAG = '[git-cleanup]';
43
44
 
45
+ /** Fields a planner verdict forwards onto its `skipped[]` entry. */
46
+ const SKIP_DETAIL_FIELDS = ['prNumber', 'tipSha', 'mergedSha', 'detail'];
47
+
44
48
  function skipEntryFromVerdict(branch, verdict) {
45
49
  const entry = { branch, reason: verdict.reason };
46
- if (verdict.prNumber != null) entry.prNumber = verdict.prNumber;
47
- if (verdict.tipSha) entry.tipSha = verdict.tipSha;
48
- if (verdict.mergedSha) entry.mergedSha = verdict.mergedSha;
50
+ for (const field of SKIP_DETAIL_FIELDS) {
51
+ if (verdict[field] != null) entry[field] = verdict[field];
52
+ }
49
53
  return entry;
50
54
  }
51
55
 
@@ -88,6 +92,7 @@ function evaluateLocalBranch({
88
92
  wtMap,
89
93
  remoteName,
90
94
  branchTipShaFn,
95
+ ancestryFn,
91
96
  contentEquivalentFn,
92
97
  branchLastCommitFn,
93
98
  }) {
@@ -102,6 +107,7 @@ function evaluateLocalBranch({
102
107
  remoteName,
103
108
  localExists: true,
104
109
  branchTipShaFn,
110
+ ancestryFn,
105
111
  });
106
112
  if (verdict.kind === 'skip') {
107
113
  return { skip: skipEntryFromVerdict(branch, verdict) };
@@ -134,6 +140,7 @@ function evaluateLocalBranch({
134
140
  worktreePath: wt?.path ?? null,
135
141
  detectedBy,
136
142
  localExists: true,
143
+ behindMerge: verdict.reason === 'tip-behind-merge',
137
144
  },
138
145
  };
139
146
  }
@@ -147,6 +154,7 @@ function collectRemoteOnlyCandidates({
147
154
  filter,
148
155
  prProbe,
149
156
  branchTipShaFn,
157
+ ancestryFn,
150
158
  skipped,
151
159
  }) {
152
160
  const out = [];
@@ -162,6 +170,7 @@ function collectRemoteOnlyCandidates({
162
170
  remoteName,
163
171
  localExists: false,
164
172
  branchTipShaFn,
173
+ ancestryFn,
165
174
  });
166
175
  if (verdict.kind === 'no-pr') continue;
167
176
  if (verdict.kind === 'skip') {
@@ -176,6 +185,7 @@ function collectRemoteOnlyCandidates({
176
185
  worktreePath: null,
177
186
  detectedBy: 'remote-only',
178
187
  localExists: false,
188
+ behindMerge: verdict.reason === 'tip-behind-merge',
179
189
  });
180
190
  }
181
191
  return out;
@@ -187,10 +197,12 @@ function collectRemoteOnlyCandidates({
187
197
  * The PR probe classifies each candidate by the **latest** PR on the head
188
198
  * ref rather than any historical merge. Branches whose latest PR is OPEN
189
199
  * or CLOSED-not-merged are skipped with `reason: 'latest-pr-open'` /
190
- * `reason: 'latest-pr-closed-not-merged'`. When the latest PR is MERGED
191
- * but the branch tip has diverged from the PR's `headRefOid` (post-merge
192
- * force-push), the branch is skipped with
193
- * `reason: 'tip-diverged-from-merge'`.
200
+ * `reason: 'latest-pr-closed-not-merged'`. A MERGED PR whose `headRefOid`
201
+ * differs from the branch tip is resolved by ancestry in
202
+ * `merged-tip.js` — a tip *behind* the merged head becomes a candidate
203
+ * carrying `behindMerge: true`, a tip *ahead* of it keeps the
204
+ * `tip-diverged-from-merge` force-push skip, and an unresolvable rev
205
+ * skips as `unverifiable`.
194
206
  *
195
207
  * Performance (Story #3333): when the caller does not inject its own
196
208
  * `prProbe`, the planner fires **one** bulk `gh pr list --state all`
@@ -250,6 +262,7 @@ export function planCleanup(ctx) {
250
262
  prIndexFn = probeAllPrs,
251
263
  prFallback = probeLatestPr,
252
264
  branchTipShaFn = branchTipSha,
265
+ ancestryFn = probeAncestry,
253
266
  contentEquivalentFn = probeContentEquivalent,
254
267
  branchLastCommitFn = branchLastCommitAt,
255
268
  refExistsFn = refExists,
@@ -301,6 +314,7 @@ export function planCleanup(ctx) {
301
314
  wtMap,
302
315
  remoteName,
303
316
  branchTipShaFn,
317
+ ancestryFn,
304
318
  contentEquivalentFn,
305
319
  branchLastCommitFn,
306
320
  });
@@ -318,6 +332,7 @@ export function planCleanup(ctx) {
318
332
  filter,
319
333
  prProbe,
320
334
  branchTipShaFn,
335
+ ancestryFn,
321
336
  skipped,
322
337
  }),
323
338
  );
@@ -17,6 +17,7 @@ import { execFileSync } from 'node:child_process';
17
17
 
18
18
  import { gitSpawn } from '../../../git-utils.js';
19
19
  import { parseWorktreePorcelain } from '../../../worktree-manager.js';
20
+ import { resolveMergedTip } from './merged-tip.js';
20
21
 
21
22
  export {
22
23
  canFastForward,
@@ -452,6 +453,12 @@ export const __testing = { validSha, firstLsRemoteSha, firstStdoutLine };
452
453
  * into `skipped[]` and continues.
453
454
  * - `{ kind: 'no-pr' }` — caller continues without skipping.
454
455
  *
456
+ * A MERGED PR whose `headRefOid` differs from the branch tip is handed
457
+ * to {@link resolveMergedTip}, which resolves it by ancestry — see that
458
+ * module for the `tip-behind-merge` / `tip-diverged-from-merge` /
459
+ * `unverifiable` taxonomy and why a bare SHA inequality could not
460
+ * express it.
461
+ *
455
462
  * @param {{
456
463
  * prInfo: { number?: number, state?: string, mergedAt?: string|null, headRefOid?: string|null } | null,
457
464
  * branch: string,
@@ -459,8 +466,10 @@ export const __testing = { validSha, firstLsRemoteSha, firstStdoutLine };
459
466
  * remoteName: string,
460
467
  * localExists: boolean,
461
468
  * branchTipShaFn: (args: { cwd: string, branch: string, remoteName: string, localExists: boolean }) => string | null,
469
+ * ancestryFn?: Function,
470
+ * mergedTipFn?: typeof resolveMergedTip,
462
471
  * }} args
463
- * @returns {{ kind: 'candidate', prInfo: object } | { kind: 'skip', reason: string, prNumber?: number, tipSha?: string|null, mergedSha?: string|null } | { kind: 'no-pr' }}
472
+ * @returns {{ kind: 'candidate', prInfo: object, reason?: string, tipSha?: string, mergedSha?: string } | { kind: 'skip', reason: string, prNumber?: number, tipSha?: string|null, mergedSha?: string|null, detail?: string } | { kind: 'no-pr' }}
464
473
  */
465
474
  export function classifyLatestPr({
466
475
  prInfo,
@@ -469,6 +478,8 @@ export function classifyLatestPr({
469
478
  remoteName,
470
479
  localExists,
471
480
  branchTipShaFn,
481
+ ancestryFn,
482
+ mergedTipFn = resolveMergedTip,
472
483
  }) {
473
484
  if (!prInfo) return { kind: 'no-pr' };
474
485
  const state =
@@ -494,17 +505,14 @@ export function classifyLatestPr({
494
505
  prNumber: prInfo.number ?? null,
495
506
  };
496
507
  }
497
- if (prInfo.headRefOid) {
498
- const tipSha = branchTipShaFn({ cwd, branch, remoteName, localExists });
499
- if (tipSha && tipSha !== prInfo.headRefOid) {
500
- return {
501
- kind: 'skip',
502
- reason: 'tip-diverged-from-merge',
503
- prNumber: prInfo.number ?? null,
504
- tipSha,
505
- mergedSha: prInfo.headRefOid,
506
- };
507
- }
508
- }
509
- return { kind: 'candidate', prInfo };
508
+ const tipVerdict = mergedTipFn({
509
+ prInfo,
510
+ branch,
511
+ cwd,
512
+ remoteName,
513
+ localExists,
514
+ branchTipShaFn,
515
+ ancestryFn,
516
+ });
517
+ return tipVerdict ?? { kind: 'candidate', prInfo };
510
518
  }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * merged-tip.js — resolve a MERGED PR's head against the branch tip
3
+ * (Story #5086).
4
+ *
5
+ * Owns the ancestry probe and the taxonomy the branches-phase classifier
6
+ * applies when a merged PR's `headRefOid` and the branch tip disagree.
7
+ * Split out of `git-probes.js` so the classifier reads as one call and
8
+ * the taxonomy's own documentation sits next to the code it governs.
9
+ *
10
+ * @module lib/orchestration/git-cleanup/phases/merged-tip
11
+ */
12
+
13
+ import { gitSpawn } from '../../../git-utils.js';
14
+
15
+ /**
16
+ * Tri-state ancestry probe: is `ancestorSha` reachable from
17
+ * `descendantSha`?
18
+ *
19
+ * Mirrors the contract `checkHeadAncestor` in
20
+ * `lib/worktree/lifecycle/merge-reachability.js` proved out for the
21
+ * worktree-reap gate — the two cannot share an implementation because
22
+ * that one takes a `ctx.git.gitSpawn` / `ctx.repoRoot` bag while
23
+ * git-cleanup's probes take a bare `cwd`.
24
+ *
25
+ * `git merge-base --is-ancestor` exits **0** (ancestor), **1** (not an
26
+ * ancestor) or **128** (a rev it cannot resolve). Folding 128 into
27
+ * "not an ancestor" is the bug this probe exists to prevent: a merged
28
+ * head absent from the local object DB would silently read as a
29
+ * divergence and re-emit the wrong post-merge-force-push diagnosis. Both
30
+ * revs are therefore resolved with `git rev-parse -q --verify` first, and
31
+ * any failure fails closed to the `error` arm — so `merge-base` never
32
+ * runs against a rev git cannot resolve.
33
+ *
34
+ * @param {{ cwd: string, ancestorSha: string, descendantSha: string, spawn?: typeof gitSpawn }} args
35
+ * @returns {{ outcome: 'ancestor' } | { outcome: 'not-ancestor' } | { outcome: 'error', reason: string }}
36
+ */
37
+ export function probeAncestry({
38
+ cwd,
39
+ ancestorSha,
40
+ descendantSha,
41
+ spawn = gitSpawn,
42
+ }) {
43
+ for (const rev of [ancestorSha, descendantSha]) {
44
+ const res = spawn(
45
+ cwd,
46
+ 'rev-parse',
47
+ '--quiet',
48
+ '--verify',
49
+ `${rev}^{commit}`,
50
+ );
51
+ if (res.status !== 0) {
52
+ return { outcome: 'error', reason: `unresolvable rev ${rev}` };
53
+ }
54
+ }
55
+ const res = spawn(
56
+ cwd,
57
+ 'merge-base',
58
+ '--is-ancestor',
59
+ ancestorSha,
60
+ descendantSha,
61
+ );
62
+ if (res.status === 0) return { outcome: 'ancestor' };
63
+ if (res.status === 1) return { outcome: 'not-ancestor' };
64
+ return {
65
+ outcome: 'error',
66
+ reason: (res.stderr || res.stdout || 'unknown').trim(),
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Resolve a MERGED PR's `headRefOid` against the branch's current tip.
72
+ *
73
+ * Returns `null` when there is nothing to resolve — the PR row carries no
74
+ * `headRefOid`, the tip cannot be read, or the tip already matches the
75
+ * merged head — leaving the caller's plain-candidate path untouched.
76
+ *
77
+ * Otherwise the tip is classified by **ancestry**, never by the bare SHA
78
+ * inequality this replaced. That inequality could not tell a branch that
79
+ * is *behind* the merged head from one force-pushed *past* it, and
80
+ * reported both as the latter — advising the operator to push a follow-up
81
+ * commit that, for a stale pre-merge snapshot, does not exist. The three
82
+ * arms:
83
+ *
84
+ * - **ancestor** — 0 commits ahead, every commit landed with the PR:
85
+ * a reap candidate tagged `reason: 'tip-behind-merge'`.
86
+ * - **not-ancestor** — equivalently "≥1 commit ahead", which is why one
87
+ * probe settles the whole taxonomy and no `rev-list` count is needed:
88
+ * the unchanged `tip-diverged-from-merge` force-push skip.
89
+ * - **error** — a rev the local object DB cannot resolve:
90
+ * `reason: 'unverifiable'` carrying the probe's `detail`. Never a
91
+ * silent pass, and never a force-push label.
92
+ *
93
+ * @param {object} args
94
+ * @returns {{ kind: 'candidate', prInfo: object, reason: string, tipSha: string, mergedSha: string } | { kind: 'skip', reason: string, prNumber: number|null, tipSha: string, mergedSha: string, detail?: string } | null}
95
+ */
96
+ export function resolveMergedTip({
97
+ prInfo,
98
+ branch,
99
+ cwd,
100
+ remoteName,
101
+ localExists,
102
+ branchTipShaFn,
103
+ ancestryFn = probeAncestry,
104
+ }) {
105
+ const mergedSha = prInfo.headRefOid;
106
+ if (!mergedSha) return null;
107
+ const tipSha = branchTipShaFn({ cwd, branch, remoteName, localExists });
108
+ if (!tipSha || tipSha === mergedSha) return null;
109
+ const ancestry = ancestryFn({
110
+ cwd,
111
+ ancestorSha: tipSha,
112
+ descendantSha: mergedSha,
113
+ });
114
+ if (ancestry.outcome === 'ancestor') {
115
+ return {
116
+ kind: 'candidate',
117
+ prInfo,
118
+ reason: 'tip-behind-merge',
119
+ tipSha,
120
+ mergedSha,
121
+ };
122
+ }
123
+ const errored = ancestry.outcome === 'error';
124
+ return {
125
+ kind: 'skip',
126
+ reason: errored ? 'unverifiable' : 'tip-diverged-from-merge',
127
+ prNumber: prInfo.number ?? null,
128
+ tipSha,
129
+ mergedSha,
130
+ ...(errored ? { detail: ancestry.reason } : {}),
131
+ };
132
+ }
@@ -61,6 +61,41 @@ function contentMergedNote(candidate) {
61
61
  : '';
62
62
  }
63
63
 
64
+ /**
65
+ * Pure: render a single behind-the-merged-head candidate annotation.
66
+ *
67
+ * A branch whose tip is a strict ancestor of its merged PR head is a
68
+ * stale pre-merge snapshot — reapable, because every commit on it landed
69
+ * with the PR, but reapable for a different reason than a branch whose
70
+ * tip *matches* the merged head. It used to be skipped outright as a
71
+ * post-merge force-push; the note keeps the two visibly distinct in the
72
+ * dry-run list and the confirmation prompt so the operator can see why a
73
+ * branch that is not at the merged head is nonetheless offered.
74
+ */
75
+ function behindMergeNote(candidate) {
76
+ return candidate.behindMerge
77
+ ? ' (tip behind the merged head — content already landed)'
78
+ : '';
79
+ }
80
+
81
+ /** Pure: every provenance annotation a candidate row carries, in order. */
82
+ function candidateNotes(candidate) {
83
+ return `${contentMergedNote(candidate)}${behindMergeNote(candidate)}`;
84
+ }
85
+
86
+ /**
87
+ * Pure: one candidate row — its detection provenance, worktree, locality
88
+ * and annotations. Split out of {@link renderDryRun} so that renderer
89
+ * stays a loop over rows rather than growing a fourth inline ternary
90
+ * every time a candidate gains a new dimension.
91
+ */
92
+ function renderCandidateRow(c) {
93
+ const pr = c.prNumber ? `PR #${c.prNumber}` : c.detectedBy;
94
+ const wt = c.hasWorktree ? ` (worktree: ${c.worktreePath})` : '';
95
+ const remoteOnly = c.localExists === false ? ' (remote-only)' : '';
96
+ return ` • ${c.branch} — ${pr}${wt}${remoteOnly}${candidateNotes(c)}`;
97
+ }
98
+
64
99
  /**
65
100
  * Pure: render the branch-phase candidate list as the operator-facing text
66
101
  * block.
@@ -87,14 +122,7 @@ export function renderDryRun(plan, opts = {}) {
87
122
  if (plan.candidates.length === 0) {
88
123
  lines.push(' (no merged branches to clean up)');
89
124
  } else {
90
- for (const c of plan.candidates) {
91
- const pr = c.prNumber ? `PR #${c.prNumber}` : c.detectedBy;
92
- const wt = c.hasWorktree ? ` (worktree: ${c.worktreePath})` : '';
93
- const remoteOnly = c.localExists === false ? ' (remote-only)' : '';
94
- lines.push(
95
- ` • ${c.branch} — ${pr}${wt}${remoteOnly}${contentMergedNote(c)}`,
96
- );
97
- }
125
+ for (const c of plan.candidates) lines.push(renderCandidateRow(c));
98
126
  }
99
127
  const skipped = plan.skipped ?? [];
100
128
  const currentHeadSkip = skipped.find((s) => s.reason === 'current-head');
@@ -141,6 +169,17 @@ export function renderCandidateList({ plan, opts = {}, baseBranch = null }) {
141
169
  return renderDryRun(plan, { baseBranch, execute: !opts.dryRun });
142
170
  }
143
171
 
172
+ /**
173
+ * Pure: the tip / merged short-SHA pair both merged-tip skip lines quote,
174
+ * with a placeholder for either side the planner could not resolve.
175
+ */
176
+ function shortShaPair(skip) {
177
+ return {
178
+ tip: skip.tipSha ? skip.tipSha.slice(0, 7) : '<unknown>',
179
+ merged: skip.mergedSha ? skip.mergedSha.slice(0, 7) : '<unknown>',
180
+ };
181
+ }
182
+
144
183
  /**
145
184
  * Pure: render a single latest-PR-state skip line. Returns null when the
146
185
  * skip reason is not one of the latest-PR family — `renderDryRun` filters
@@ -148,7 +187,7 @@ export function renderCandidateList({ plan, opts = {}, baseBranch = null }) {
148
187
  * `current-head`, `filtered`) stay quiet here. `not-merged` gets its own
149
188
  * renderer ({@link renderNotMergedSkipLine}).
150
189
  *
151
- * @param {{ branch: string, reason: string, prNumber?: number, tipSha?: string, mergedSha?: string }} skip
190
+ * @param {{ branch: string, reason: string, prNumber?: number, tipSha?: string, mergedSha?: string, detail?: string }} skip
152
191
  * @returns {string | null}
153
192
  */
154
193
  export function renderLatestPrSkipLine(skip) {
@@ -161,13 +200,19 @@ export function renderLatestPrSkipLine(skip) {
161
200
  return `${TAG} ⏭️ ${skip.branch} skipped — ${prRef} is still open`;
162
201
  }
163
202
  if (skip.reason === 'tip-diverged-from-merge') {
164
- const tip = skip.tipSha ? skip.tipSha.slice(0, 7) : '<unknown>';
165
- const merged = skip.mergedSha ? skip.mergedSha.slice(0, 7) : '<unknown>';
203
+ const { tip, merged } = shortShaPair(skip);
166
204
  return (
167
205
  `${TAG} ⏭️ ${skip.branch} skipped — tip ${tip} diverges from ${prRef}'s merged ${merged} (post-merge force-push); ` +
168
206
  `resolve by deleting manually (\`git branch -D ${skip.branch}\`) or pushing the follow-up commit`
169
207
  );
170
208
  }
209
+ if (skip.reason === 'unverifiable') {
210
+ const { tip, merged } = shortShaPair(skip);
211
+ return (
212
+ `${TAG} ⏭️ ${skip.branch} skipped — cannot verify tip ${tip} against ${prRef}'s merged ${merged}${skip.detail ? `: ${skip.detail}` : ''}; ` +
213
+ `fetch the missing commit or inspect the branch by hand before deleting it`
214
+ );
215
+ }
171
216
  if (skip.reason === 'latest-pr-unknown-state') {
172
217
  return `${TAG} ⏭️ ${skip.branch} skipped — ${prRef} has an unrecognized state`;
173
218
  }
@@ -96,7 +96,15 @@ export const BLOCK_CLASSES = Object.freeze([
96
96
  * This is the classifier's four outputs PLUS `predicate-refused` (#4472),
97
97
  * emitted DIRECTLY for a headless refusal that never reached the
98
98
  * poll-exhaustion classifier — so it is a valid attribution value even though
99
- * `classifyMergeBlock` never produces it. (The Epic-era listeners that used to
99
+ * `classifyMergeBlock` never produces it. Story #5096 added
100
+ * `advisory-gate-red` on the same footing: a genuinely red NON-required check
101
+ * observed while GitHub reports the PR mergeable anyway
102
+ * (`mergeStateStatus: UNSTABLE`), which native auto-merge would land straight
103
+ * past. It is emitted directly by the arm and merge-wait phases — the
104
+ * classifier cannot produce it, because by construction GitHub is NOT blocking
105
+ * the merge, which is the entire problem it names. It is deliberately NOT in
106
+ * `BLOCK_CLASSES`, whose reachability invariant covers only what
107
+ * `classifyMergeBlock` returns. (The Epic-era listeners that used to
100
108
  * emit it, AutomergePredicate and AutomergeArmer, are gone; the value stays
101
109
  * because archived `merge.unlanded` records carry it and the schema enum
102
110
  * must keep validating them.) `isValidBlockClass` (and the `merge.unlanded` schema enum)
@@ -106,6 +114,7 @@ export const BLOCK_CLASSES = Object.freeze([
106
114
  export const MERGE_UNLANDED_BLOCK_CLASSES = Object.freeze([
107
115
  ...BLOCK_CLASSES,
108
116
  'predicate-refused',
117
+ 'advisory-gate-red',
109
118
  ]);
110
119
 
111
120
  const BLOCK_CLASS_SET = new Set(MERGE_UNLANDED_BLOCK_CLASSES);