mandrel 2.35.0 → 2.37.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 (108) hide show
  1. package/.agents/docs/agentrc-reference.json +3 -1
  2. package/.agents/docs/configuration.md +27 -0
  3. package/.agents/rules/testing-standards.md +56 -0
  4. package/.agents/schemas/agentrc.schema.json +11 -0
  5. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  6. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  7. package/.agents/scripts/boot-sweep.js +7 -1
  8. package/.agents/scripts/check-context-budget.js +195 -103
  9. package/.agents/scripts/check-cyclomatic.js +112 -42
  10. package/.agents/scripts/check-doc-links.js +23 -2
  11. package/.agents/scripts/check-generated-validator.js +202 -0
  12. package/.agents/scripts/check-lifecycle-lint.js +2 -68
  13. package/.agents/scripts/check-schema-references.js +2 -28
  14. package/.agents/scripts/drain-pending-cleanup.js +18 -1
  15. package/.agents/scripts/git-cleanup.js +2 -0
  16. package/.agents/scripts/lib/baselines/components.js +32 -2
  17. package/.agents/scripts/lib/baselines/env-overrides.js +1 -1
  18. package/.agents/scripts/lib/bootstrap/branch-protection.js +1 -1
  19. package/.agents/scripts/lib/bootstrap/install-ledger.js +1 -1
  20. package/.agents/scripts/lib/bootstrap/prompt.js +1 -1
  21. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +2 -2
  22. package/.agents/scripts/lib/checks/loop-health.js +1 -1
  23. package/.agents/scripts/lib/cli-args.js +0 -2
  24. package/.agents/scripts/lib/close-validation/gates.js +1 -1
  25. package/.agents/scripts/lib/close-validation/process.js +1 -1
  26. package/.agents/scripts/lib/config/ci.js +18 -0
  27. package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
  28. package/.agents/scripts/lib/config-settings-schema-delivery.js +13 -0
  29. package/.agents/scripts/lib/config-settings-schema.js +46 -6
  30. package/.agents/scripts/lib/coverage-baseline.js +2 -2
  31. package/.agents/scripts/lib/cpu-pool.js +90 -10
  32. package/.agents/scripts/lib/crap-utils.js +6 -2
  33. package/.agents/scripts/lib/cyclomatic-ceiling.js +28 -6
  34. package/.agents/scripts/lib/cyclomatic-scope.js +144 -0
  35. package/.agents/scripts/lib/dependency-version.js +86 -0
  36. package/.agents/scripts/lib/duplicate-search.js +1 -1
  37. package/.agents/scripts/lib/errors/index.js +1 -1
  38. package/.agents/scripts/lib/feedback-loop/graduator-core.js +2 -2
  39. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +1 -1
  40. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +1 -1
  41. package/.agents/scripts/lib/generated/agentrc-validator.js +16 -0
  42. package/.agents/scripts/lib/install-cmd-parser.js +1 -1
  43. package/.agents/scripts/lib/knip-entry-sync.js +1 -57
  44. package/.agents/scripts/lib/maintainability-utils.js +108 -10
  45. package/.agents/scripts/lib/observability/source-classifier.js +1 -1
  46. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +22 -7
  47. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -14
  48. package/.agents/scripts/lib/orchestration/git-cleanup/phases/merged-tip.js +132 -0
  49. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +56 -11
  50. package/.agents/scripts/lib/orchestration/merge-block-class.js +10 -1
  51. package/.agents/scripts/lib/orchestration/merge-poll.js +164 -0
  52. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +70 -9
  53. package/.agents/scripts/lib/orchestration/pr-watch.js +1 -30
  54. package/.agents/scripts/lib/orchestration/reassert-status-column.js +2 -2
  55. package/.agents/scripts/lib/orchestration/review-providers/codex.js +3 -3
  56. package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +1 -1
  57. package/.agents/scripts/lib/orchestration/review-providers/native.js +9 -2
  58. package/.agents/scripts/lib/orchestration/review-providers/review-depth.js +2 -2
  59. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -3
  60. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +145 -0
  61. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -5
  62. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +52 -1
  63. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +9 -1
  64. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +2 -5
  65. package/.agents/scripts/lib/orchestration/story-follow-ups.js +1 -1
  66. package/.agents/scripts/lib/orchestration/ticket-lease.js +34 -9
  67. package/.agents/scripts/lib/orchestration/ticketing/reads.js +3 -1
  68. package/.agents/scripts/lib/preflight-runner.js +3 -3
  69. package/.agents/scripts/lib/qa/qa-session.js +1 -1
  70. package/.agents/scripts/lib/runtime-deps/manifest.js +1 -7
  71. package/.agents/scripts/lib/runtime-deps/scan-imports.js +3 -58
  72. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +284 -25
  73. package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
  74. package/.agents/scripts/lib/source-text/strip-js-comments.js +115 -0
  75. package/.agents/scripts/lib/test-isolate/runner.js +3 -3
  76. package/.agents/scripts/lib/test-runner-contract.js +14 -6
  77. package/.agents/scripts/lib/test-tiers.js +135 -18
  78. package/.agents/scripts/lib/transpile.js +16 -3
  79. package/.agents/scripts/lib/worktree/lifecycle/pending-cleanup.js +169 -31
  80. package/.agents/scripts/lib/worktree/node-modules-strategy.js +2 -5
  81. package/.agents/scripts/notify.js +4 -10
  82. package/.agents/scripts/pr-watch-with-update.js +305 -137
  83. package/.agents/scripts/providers/github/tickets.js +109 -16
  84. package/.agents/scripts/quality-preview.js +162 -70
  85. package/.agents/scripts/run-test-profile.js +8 -5
  86. package/.agents/scripts/run-tests.js +79 -14
  87. package/.agents/scripts/single-story-close.js +0 -2
  88. package/.agents/scripts/single-story-init.js +5 -1
  89. package/.agents/workflows/audit-documentation.md +5 -6
  90. package/docs/CHANGELOG.md +50 -0
  91. package/lib/cli/migrate.js +100 -94
  92. package/lib/migrations/helpers/retire-agentrc-key.js +200 -0
  93. package/lib/migrations/index.js +32 -33
  94. package/lib/migrations/steps/2.1.0-retire-mi-drop-knobs.js +21 -80
  95. package/lib/migrations/steps/2.1.0-retire-verify-concurrency-cap.js +15 -87
  96. package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +13 -71
  97. package/lib/migrations/steps/2.20.0-retire-codebase-snapshot.js +13 -101
  98. package/lib/migrations/steps/2.32.0-retire-lint-baseline-command.js +13 -100
  99. package/package.json +9 -5
  100. package/.agents/scripts/generate-lifecycle-docs.js +0 -237
  101. package/.agents/scripts/lib/audit-suite/frontmatter-lint.js +0 -32
  102. package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +0 -37
  103. package/.agents/scripts/lib/cli/parse-numeric.js +0 -60
  104. package/.agents/scripts/lib/close-validation/telemetry.js +0 -79
  105. package/.agents/scripts/lib/orchestration/label-transitions.js +0 -44
  106. package/.agents/scripts/lib/orchestration/parked-follow-ons.js +0 -147
  107. package/.agents/scripts/lib/orchestration/phase-runner.js +0 -88
  108. package/.agents/scripts/lib/orchestration/recut.js +0 -56
@@ -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);
@@ -243,6 +243,170 @@ export function requiredCheckFailedBlocksMerge(prProbe) {
243
243
  );
244
244
  }
245
245
 
246
+ /**
247
+ * The one `mergeStateStatus` value that means the PR is mergeable **despite**
248
+ * red runs — GitHub's own words are "mergeable with non-passing commit
249
+ * status". It is the required-vs-advisory discriminator the rollup itself
250
+ * cannot supply: under `UNSTABLE` the red runs are, by definition, not
251
+ * required, so native auto-merge will land the PR over them.
252
+ *
253
+ * The exact complement of {@link MERGE_GATED_STATE}: `BLOCKED` means the red
254
+ * run gates the merge (`failingChecksBlockMerge`), `UNSTABLE` means it does
255
+ * not and only mandrel can stop the landing.
256
+ */
257
+ const MERGE_ADVISORY_STATE = 'UNSTABLE';
258
+
259
+ /**
260
+ * Pure: project the HEAD-ANCHORED runs that genuinely concluded red, naming
261
+ * each one (Story #5096).
262
+ *
263
+ * Same red-ness test as {@link deriveRequiredRunEvidence} — `FAILURE` /
264
+ * `ERROR` only, never `CANCELLED` / `TIMED_OUT` / `SKIPPED`, which are the
265
+ * superseded-push and sibling-invalidated runs a bare rollup read miscounts
266
+ * (the #4695 / #4710 trap) — but it returns the runs rather than a boolean, so
267
+ * a block summary can name the offending job and the advisory allowlist can
268
+ * match on it.
269
+ *
270
+ * `name` is the CheckRun's `name`, falling back to a legacy StatusContext's
271
+ * `context`, and is `null` when the projection carries neither. A run with no
272
+ * readable name can never match an allowlist entry, so it always blocks — the
273
+ * conservative direction for a gate whose whole purpose is to stop a silent
274
+ * landing.
275
+ *
276
+ * @param {Array<{name?: string, context?: string, status?: string, conclusion?: string, state?: string}>} statusCheckRollup
277
+ * @returns {Array<{ name: string|null, conclusion: string }>}
278
+ */
279
+ export function deriveRedHeadRuns(statusCheckRollup) {
280
+ if (!Array.isArray(statusCheckRollup)) return [];
281
+ const red = [];
282
+ for (const check of statusCheckRollup) {
283
+ const conclusion = String(check?.conclusion ?? '').toUpperCase();
284
+ const state = String(check?.state ?? '').toUpperCase();
285
+ const isRed =
286
+ conclusion === 'FAILURE' ||
287
+ conclusion === 'ERROR' ||
288
+ state === 'FAILURE' ||
289
+ state === 'ERROR';
290
+ if (!isRed) continue;
291
+ const name =
292
+ typeof check?.name === 'string' && check.name
293
+ ? check.name
294
+ : typeof check?.context === 'string' && check.context
295
+ ? check.context
296
+ : null;
297
+ red.push({ name, conclusion: conclusion || state });
298
+ }
299
+ return red;
300
+ }
301
+
302
+ /**
303
+ * Pure: drop the red runs a consumer has exempted via
304
+ * `delivery.ci.advisoryAllowlist`, returning the ones that still block.
305
+ *
306
+ * Matching is exact on the run name. An unnamed run never matches (see
307
+ * {@link deriveRedHeadRuns}).
308
+ *
309
+ * @param {Array<{ name: string|null, conclusion: string }>} redHeadRuns
310
+ * @param {string[]} [allowlist]
311
+ * @returns {Array<{ name: string|null, conclusion: string }>}
312
+ */
313
+ export function selectBlockingRedRuns(redHeadRuns, allowlist = []) {
314
+ if (!Array.isArray(redHeadRuns) || redHeadRuns.length === 0) return [];
315
+ const exempt = new Set(
316
+ (Array.isArray(allowlist) ? allowlist : [])
317
+ .filter((entry) => typeof entry === 'string' && entry)
318
+ .map((entry) => entry),
319
+ );
320
+ if (exempt.size === 0) return [...redHeadRuns];
321
+ return redHeadRuns.filter((run) => !(run?.name && exempt.has(run.name)));
322
+ }
323
+
324
+ /**
325
+ * Pure: does this PR carry a genuinely red ADVISORY run — one that will NOT
326
+ * stop GitHub from landing the PR, and therefore one only mandrel can act on?
327
+ * (Story #5096.)
328
+ *
329
+ * The complement of {@link requiredCheckFailedBlocksMerge}. That predicate
330
+ * answers "is a red REQUIRED check gating the merge" (`BLOCKED`); this one
331
+ * answers "is a red NON-required check about to be merged straight past"
332
+ * (`UNSTABLE`). The two are mutually exclusive by construction, so a red
333
+ * required check keeps its existing `checks-failed` treatment untouched.
334
+ *
335
+ * **Fails OPEN by design.** `UNKNOWN`, `CLEAN`, `BEHIND`, or an absent
336
+ * `mergeStateStatus` all return `false`. The asymmetry is the same one
337
+ * {@link failingChecksBlockMerge} documents and is deliberate: failing to
338
+ * block costs an unattended landing the operator can still revert, whereas
339
+ * blocking wrongly strands a mergeable PR at `agent::blocked` that only an
340
+ * operator can unpick. A transient `UNKNOWN` must never do the latter.
341
+ *
342
+ * @param {{ mergeStateStatus?: string, redHeadRuns?: Array<{name: string|null, conclusion: string}> }} [prProbe]
343
+ * @param {string[]} [allowlist] `delivery.ci.advisoryAllowlist`.
344
+ * @returns {boolean}
345
+ */
346
+ export function advisoryCheckFailedBlocksArm(prProbe, allowlist = []) {
347
+ if (
348
+ String(prProbe?.mergeStateStatus ?? '').toUpperCase() !==
349
+ MERGE_ADVISORY_STATE
350
+ ) {
351
+ return false;
352
+ }
353
+ return selectBlockingRedRuns(prProbe?.redHeadRuns, allowlist).length > 0;
354
+ }
355
+
356
+ /**
357
+ * Pure: the merge wait's advisory-gate decision, the sibling of
358
+ * {@link decideMergeWaitFailFast} (Story #5096).
359
+ *
360
+ * Encapsulates the whole policy — the knob, the predicate, and the allowlist
361
+ * projection — so the poll body carries a single assignment rather than three
362
+ * decision points. `runMergePoll` sits above `check-cyclomatic`'s ceiling
363
+ * already; every branch added inline there is a real regression, and this
364
+ * policy has a natural home beside the predicate it consumes.
365
+ *
366
+ * Returns `null` when the wait should keep polling — the knob is off, the PR
367
+ * is not in the advisory-red state, or every red run is allowlisted.
368
+ *
369
+ * @param {object} args
370
+ * @returns {{ blockingRuns: Array<object>, reason: string } | null}
371
+ */
372
+ export function decideAdvisoryGateBlock({
373
+ probe,
374
+ blockOnAdvisoryFailure,
375
+ advisoryAllowlist,
376
+ }) {
377
+ if (!blockOnAdvisoryFailure) return null;
378
+ if (!advisoryCheckFailedBlocksArm(probe, advisoryAllowlist)) return null;
379
+ const blockingRuns = selectBlockingRedRuns(
380
+ probe?.redHeadRuns,
381
+ advisoryAllowlist,
382
+ );
383
+ return { blockingRuns, reason: formatAdvisoryGateReason(blockingRuns) };
384
+ }
385
+
386
+ /**
387
+ * Format the one-line reason a `merge.unlanded` record and the operator-facing
388
+ * block carry for an `advisory-gate-red` verdict, naming each offending job
389
+ * and its conclusion.
390
+ *
391
+ * @param {Array<{ name: string|null, conclusion: string }>} blockingRuns
392
+ * @returns {string}
393
+ */
394
+ export function formatAdvisoryGateReason(blockingRuns) {
395
+ const named = (Array.isArray(blockingRuns) ? blockingRuns : [])
396
+ .map(
397
+ (run) =>
398
+ `${run?.name ?? '(unnamed run)'} → ${run?.conclusion ?? 'FAILURE'}`,
399
+ )
400
+ .join(', ');
401
+ return (
402
+ 'A non-required (advisory) check concluded red on the PR head, and GitHub ' +
403
+ 'reports the PR mergeable anyway (mergeStateStatus=UNSTABLE) — native ' +
404
+ 'auto-merge would land it over the failure. Red advisory job(s): ' +
405
+ `${named || '(none named)'}. Merge by hand to land over it deliberately, ` +
406
+ 'or exempt the job via delivery.ci.advisoryAllowlist.'
407
+ );
408
+ }
409
+
246
410
  /**
247
411
  * Pure: the merge wait's single fail-fast decision (Story #4710 — extracted
248
412
  * from the two near-verbatim inline blocks in `runConfirmMergePhase`'s poll
@@ -197,6 +197,26 @@ function planFingerprintMarker(fingerprint) {
197
197
  return `<!-- ${PLAN_FINGERPRINT_MARKER_PREFIX} ${fingerprint} -->`;
198
198
  }
199
199
 
200
+ /** The one parser for the marker {@link planFingerprintMarker} renders. */
201
+ const PLAN_FINGERPRINT_MARKER_RE = new RegExp(
202
+ `<!--\\s*${PLAN_FINGERPRINT_MARKER_PREFIX}\\s*([0-9a-f]+)\\s*-->`,
203
+ );
204
+
205
+ /**
206
+ * Recover the plan fingerprint an issue body was stamped with, or `null` when
207
+ * the body carries no marker. Module-private: both readers of the marker (the
208
+ * resume index and the create-retry adoption probe) go through it so they can
209
+ * never drift into recognising different Stories as "already created".
210
+ *
211
+ * @param {unknown} body
212
+ * @returns {string|null}
213
+ */
214
+ function extractPlanFingerprint(body) {
215
+ if (typeof body !== 'string') return null;
216
+ const match = body.match(PLAN_FINGERPRINT_MARKER_RE);
217
+ return match ? match[1] : null;
218
+ }
219
+
200
220
  /**
201
221
  * Labels the authoring pass is never allowed to set. The `agent::*` axis is
202
222
  * the runtime's lifecycle state (persist owns the terminal `agent::ready`
@@ -617,14 +637,9 @@ async function indexExistingStories(provider) {
617
637
  if (title !== '') {
618
638
  idsByTitle.set(title, [...(idsByTitle.get(title) ?? []), id]);
619
639
  }
620
- const body = typeof issue?.body === 'string' ? issue.body : '';
621
- const match = body.match(
622
- new RegExp(
623
- `<!--\\s*${PLAN_FINGERPRINT_MARKER_PREFIX}\\s*([0-9a-f]+)\\s*-->`,
624
- ),
625
- );
626
- if (!match) continue;
627
- byFingerprint.set(match[1], {
640
+ const fingerprint = extractPlanFingerprint(issue?.body);
641
+ if (!fingerprint) continue;
642
+ byFingerprint.set(fingerprint, {
628
643
  id,
629
644
  title,
630
645
  url: issue.html_url ?? issue.url ?? undefined,
@@ -633,6 +648,42 @@ async function indexExistingStories(provider) {
633
648
  return { byFingerprint, idsByTitle };
634
649
  }
635
650
 
651
+ /**
652
+ * Re-run the resume lookup for a single fingerprint and return the **raw**
653
+ * issue, or `null`.
654
+ *
655
+ * This is the probe `createIssue` calls before any retry POST (Story #5112).
656
+ * A create whose response was lost has already filed the issue; retrying
657
+ * blind duplicates it. Because the body posted carries the fingerprint
658
+ * marker, the same content-keyed lookup the resume path uses answers "did
659
+ * attempt 1 land?" authoritatively — from the server's state, not from a
660
+ * client-side guess about where the connection broke.
661
+ *
662
+ * Best-effort like {@link indexExistingStories}: a provider without the
663
+ * listing surface, or a listing that throws, yields `null` and the retry
664
+ * proceeds exactly as it did before.
665
+ *
666
+ * @param {{ provider: object, fingerprint: string }} args
667
+ * @returns {Promise<object|null>}
668
+ */
669
+ async function findOpenStoryByPlanFingerprint({ provider, fingerprint }) {
670
+ if (typeof provider?.listIssuesByLabel !== 'function') return null;
671
+ if (typeof fingerprint !== 'string' || fingerprint.length === 0) return null;
672
+ let issues;
673
+ try {
674
+ issues = await provider.listIssuesByLabel({
675
+ state: 'open',
676
+ labels: TYPE_LABELS.STORY,
677
+ });
678
+ } catch {
679
+ return null;
680
+ }
681
+ for (const issue of Array.isArray(issues) ? issues : []) {
682
+ if (extractPlanFingerprint(issue?.body) === fingerprint) return issue;
683
+ }
684
+ return null;
685
+ }
686
+
636
687
  /**
637
688
  * Warn when a Story with this title is already open but did **not** match the
638
689
  * fingerprint — i.e. its authored content differs from what this run is about
@@ -1000,6 +1051,14 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
1000
1051
  ...(applyCohortLabel ? [cohortLabel] : []),
1001
1052
  ...(applyRouteLabel ? [routeLabel] : []),
1002
1053
  ],
1054
+ // Story #5112 — hand the provider the same content-keyed lookup this
1055
+ // loop's resume path uses, so a retry after a lost response adopts the
1056
+ // issue attempt 1 already filed instead of creating a twin.
1057
+ findExisting: () =>
1058
+ findOpenStoryByPlanFingerprint({
1059
+ provider,
1060
+ fingerprint: story.fingerprint,
1061
+ }),
1003
1062
  });
1004
1063
  const id = result?.id ?? result?.number;
1005
1064
  if (!Number.isInteger(id)) {
@@ -1012,7 +1071,9 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
1012
1071
  id,
1013
1072
  title: story.title,
1014
1073
  url: result.url,
1015
- adopted: false,
1074
+ // True when the provider's retry probe adopted an issue a lost-response
1075
+ // first attempt had already filed — pre-existing either way.
1076
+ adopted: result.adopted === true,
1016
1077
  });
1017
1078
  idBySlug.set(story.slug, id);
1018
1079
  }
@@ -50,35 +50,6 @@ import { applyBehindUpdate } from './behind-recovery.js';
50
50
  * values collapse to `'skipped'` so any future GitHub state we haven't
51
51
  * enumerated still maps into the vocabulary.
52
52
  */
53
- /**
54
- * The raw check-state tokens `normalizeCheckState` recognizes (lowercased).
55
- * A token absent from this set is one we have NOT enumerated — the watch
56
- * path collapses it to `'skipped'` (validate-anything), but a fail-closed
57
- * consumer (the auto-merge arming probe) must treat it as unknown-therefore-
58
- * blocking rather than trust the `'skipped'` collapse. Exported so that
59
- * stricter consumer lives here as the single vocabulary owner.
60
- */
61
- export const RECOGNIZED_CHECK_STATES = Object.freeze(
62
- new Set([
63
- '',
64
- 'pending',
65
- 'queued',
66
- 'in_progress',
67
- 'requested',
68
- 'waiting',
69
- 'success',
70
- 'completed',
71
- 'failure',
72
- 'startup_failure',
73
- 'neutral',
74
- 'cancelled',
75
- 'timed_out',
76
- 'action_required',
77
- 'stale',
78
- 'skipped',
79
- ]),
80
- );
81
-
82
53
  export function normalizeCheckState(raw) {
83
54
  const v = String(raw ?? '')
84
55
  .trim()
@@ -325,7 +296,7 @@ export function allTerminal(outcomes) {
325
296
  * red check): a `'still-running'` map means "re-arm the watch / hand off
326
297
  * to `/loop`," never "the change is broken."
327
298
  */
328
- export const STILL_RUNNING = 'still-running';
299
+ const STILL_RUNNING = 'still-running';
329
300
 
330
301
  /**
331
302
  * Promote any leftover `'pending'` outcomes to the schema-valid
@@ -38,14 +38,14 @@ import { ColumnSync, columnForLabels } from './column-sync.js';
38
38
  * long enough to outlast the typical GitHub built-in workflow fire
39
39
  * timing without ballooning the close-path wall clock.
40
40
  */
41
- export const DEFAULT_POLL_ATTEMPTS = 4;
41
+ const DEFAULT_POLL_ATTEMPTS = 4;
42
42
 
43
43
  /**
44
44
  * Default delay between drift checks (ms). Five seconds is a sweet
45
45
  * spot: short enough that the operator doesn't perceive the close
46
46
  * path as hung, long enough that one re-fire usually wins the race.
47
47
  */
48
- export const DEFAULT_POLL_DELAY_MS = 5000;
48
+ const DEFAULT_POLL_DELAY_MS = 5000;
49
49
 
50
50
  /**
51
51
  * Production sleep function. Tests inject `sleepFn: () => Promise.resolve()`
@@ -57,7 +57,7 @@ export const CODEX_REMEDIATIONS = Object.freeze({
57
57
  *
58
58
  * Exported so tests can extend the list without monkey-patching `os`.
59
59
  */
60
- export const DEFAULT_PLUGIN_MARKERS = Object.freeze([
60
+ const DEFAULT_PLUGIN_MARKERS = Object.freeze([
61
61
  path.join(os.homedir(), '.claude', 'plugins', 'codex-plugin-cc'),
62
62
  path.join(os.homedir(), '.claude', 'plugins', 'openai', 'codex-plugin-cc'),
63
63
  ]);
@@ -194,7 +194,7 @@ export function parseCodexFindings(rawStdout) {
194
194
  * @param {{ baseRef: string, headRef: string, depth?: import('./types.js').ReviewDepth }} args
195
195
  * @returns {string}
196
196
  */
197
- export function buildCodexReviewPrompt({ baseRef, headRef, depth }) {
197
+ function buildCodexReviewPrompt({ baseRef, headRef, depth }) {
198
198
  return (
199
199
  `/codex:review --base ${baseRef} --head ${headRef} --wait ` +
200
200
  `${renderDepthDirective(depth)}`
@@ -214,7 +214,7 @@ export function buildCodexReviewPrompt({ baseRef, headRef, depth }) {
214
214
  * @param {{ baseRef: string, headRef: string, depth?: import('./types.js').ReviewDepth }} args
215
215
  * @returns {{ status: number, stdout: string, stderr: string }}
216
216
  */
217
- export function defaultInvokeCodexReview({ baseRef, headRef, depth }) {
217
+ function defaultInvokeCodexReview({ baseRef, headRef, depth }) {
218
218
  const cliArgs = [
219
219
  '--print',
220
220
  buildCodexReviewPrompt({ baseRef, headRef, depth }),
@@ -102,7 +102,7 @@ export function renderFinding(finding) {
102
102
  * @param {ReadonlyArray<string>} messages
103
103
  * @returns {string[]} lines to append (empty when no messages)
104
104
  */
105
- export function renderManualPromptsSection(messages) {
105
+ function renderManualPromptsSection(messages) {
106
106
  const filtered = Array.isArray(messages)
107
107
  ? messages.filter((m) => typeof m === 'string' && m.trim().length > 0)
108
108
  : [];
@@ -267,8 +267,14 @@ function isJsMaintainabilityFile(relPath) {
267
267
  * `reportFn` forces the serial path (the injected scorer cannot cross the
268
268
  * worker boundary).
269
269
  *
270
+ * `serialThreshold` overrides the pool-vs-serial cutover for one call. It is
271
+ * the seam the parity test uses to drive the pooled branch: Story #5109
272
+ * retuned `POOL_SERIAL_THRESHOLD` from 8 to 256 against measured data, and a
273
+ * fixture sized to clear the old number would otherwise have gone quietly
274
+ * serial while still claiming to cover the pool.
275
+ *
270
276
  * @param {string[]} changedFiles
271
- * @param {{ reportFn?: Function, classifier?: Function, runOnPoolFn?: typeof runOnPool, headRef?: string|null, gitSpawnFn?: typeof gitSpawn, readHeadSourceFn?: typeof readHeadSource }} [deps]
277
+ * @param {{ reportFn?: Function, classifier?: Function, runOnPoolFn?: typeof runOnPool, headRef?: string|null, gitSpawnFn?: typeof gitSpawn, readHeadSourceFn?: typeof readHeadSource, serialThreshold?: number }} [deps]
272
278
  * @returns {Promise<{ totalFiles: number, jsFiles: number, maintainability: object[], criticalFindings: Finding[], mediumFindings: Finding[] }>}
273
279
  */
274
280
  export async function analyzeChangedFiles(
@@ -280,6 +286,7 @@ export async function analyzeChangedFiles(
280
286
  headRef = null,
281
287
  gitSpawnFn = gitSpawn,
282
288
  readHeadSourceFn = readHeadSource,
289
+ serialThreshold = SERIAL_THRESHOLD,
283
290
  } = {},
284
291
  ) {
285
292
  const results = {
@@ -308,7 +315,7 @@ export async function analyzeChangedFiles(
308
315
  const customReportFn = reportFn != null;
309
316
 
310
317
  // Serial path: small batches, or whenever a caller injects its own scorer.
311
- if (jsFiles.length < SERIAL_THRESHOLD || customReportFn) {
318
+ if (jsFiles.length < serialThreshold || customReportFn) {
312
319
  for (let i = 0; i < jsFiles.length; i += 1) {
313
320
  const relPath = jsFiles[i];
314
321
  const source = sources[i];
@@ -28,7 +28,7 @@
28
28
  *
29
29
  * @type {Readonly<Record<ReviewDepth, string>>}
30
30
  */
31
- export const DEPTH_DIRECTIVES = Object.freeze({
31
+ const DEPTH_DIRECTIVES = Object.freeze({
32
32
  light:
33
33
  'Review depth: LIGHT. Run a single pass focused on spec adherence over the ' +
34
34
  'changed surface — confirm the change matches its stated intent. Reduce the ' +
@@ -54,7 +54,7 @@ export const DEPTH_DIRECTIVES = Object.freeze({
54
54
  * @param {unknown} depth
55
55
  * @returns {ReviewDepth}
56
56
  */
57
- export function normalizeDepth(depth) {
57
+ function normalizeDepth(depth) {
58
58
  return depth === 'light' || depth === 'deep' ? depth : 'standard';
59
59
  }
60
60
 
@@ -59,7 +59,7 @@ export const SECURITY_REVIEW_REMEDIATIONS = Object.freeze({
59
59
  * @param {{ spawnFn?: typeof spawnSync }} [opts]
60
60
  * @returns {boolean}
61
61
  */
62
- export function defaultProbeClaudeCli(opts = {}) {
62
+ function defaultProbeClaudeCli(opts = {}) {
63
63
  const spawnFn = opts.spawnFn ?? spawnSync;
64
64
  try {
65
65
  const result = spawnFn('claude', ['--version'], {
@@ -162,7 +162,7 @@ export function parseSecurityReviewFindings(rawStdout) {
162
162
  * (Story #3937) so a high-risk Epic instructs the model toward a deeper
163
163
  * second-pass review while a low-risk one keeps it light.
164
164
  */
165
- export const SECURITY_REVIEW_INVOKE_PROMPT =
165
+ const SECURITY_REVIEW_INVOKE_PROMPT =
166
166
  'Run /security-review against the diff `{baseRef}`...`{headRef}` ' +
167
167
  'for {scopeLabel} #{ticketId}. {depthDirective} After the review, emit ' +
168
168
  'ONLY a JSON array of findings on stdout with this exact shape:\n\n' +
@@ -208,7 +208,7 @@ export function buildSecurityReviewPrompt(input) {
208
208
  * @param {ReviewInput} input
209
209
  * @returns {{ status: number, stdout: string, stderr: string }}
210
210
  */
211
- export function defaultInvokeSecurityReview(input) {
211
+ function defaultInvokeSecurityReview(input) {
212
212
  const prompt = buildSecurityReviewPrompt(input);
213
213
  const result = spawnSync('claude', ['--print', prompt], {
214
214
  encoding: 'utf-8',
@@ -63,6 +63,12 @@
63
63
 
64
64
  import { gh as defaultGh } from '../../../gh-exec.js';
65
65
  import { resolveAutoMergeArmCwd } from '../../auto-merge-cwd.js';
66
+ import {
67
+ advisoryCheckFailedBlocksArm,
68
+ deriveRedHeadRuns,
69
+ formatAdvisoryGateReason,
70
+ selectBlockingRedRuns,
71
+ } from '../../merge-poll.js';
66
72
 
67
73
  /**
68
74
  * Arm reasons that mean **the operator deliberately owns the merge** — the PR
@@ -321,6 +327,116 @@ function makeDefaultGhAutoMergeRunner(gh) {
321
327
  };
322
328
  }
323
329
 
330
+ /**
331
+ * Evaluate the pre-arm advisory-gate verdict (Story #5096).
332
+ *
333
+ * GitHub native auto-merge is defined to wait on REQUIRED contexts only, so a
334
+ * red ADVISORY quality gate — a coverage, mutation, duplication, a11y or
335
+ * bundle-size ratchet a consumer deliberately left non-required — is merged
336
+ * straight past once the required contexts go green. The arming decision is
337
+ * mandrel's, not GitHub's, so this is where it has to be made.
338
+ *
339
+ * Reads the SAME head-anchored derivation the merge wait uses
340
+ * (`readPrWaitProbe` → `deriveRedHeadRuns`), so a `CANCELLED` superseded-push
341
+ * run or a sibling-invalidated run is never mistaken for a red gate (the
342
+ * #4695 / #4710 trap), and `mergeStateStatus: UNSTABLE` supplies the
343
+ * required-vs-advisory discrimination the rollup itself lacks.
344
+ *
345
+ * **Non-fatal and fail-open in every degraded case.** A probe error, an absent
346
+ * or `UNKNOWN` merge state, or a disabled knob all return "do not block" — the
347
+ * arm proceeds exactly as it did before this Story. Only a positive verdict
348
+ * refuses.
349
+ *
350
+ * @param {object} args
351
+ * @returns {Promise<{ blocked: boolean, blockingRuns?: Array<object>, reason?: string }>}
352
+ */
353
+ async function readAdvisoryProbe({ prNumber, gh }) {
354
+ const view = await (gh ?? defaultGh).pr.view(prNumber, [
355
+ 'mergeStateStatus',
356
+ 'statusCheckRollup',
357
+ ]);
358
+ return {
359
+ mergeStateStatus:
360
+ typeof view?.mergeStateStatus === 'string'
361
+ ? view.mergeStateStatus
362
+ : undefined,
363
+ redHeadRuns: deriveRedHeadRuns(view?.statusCheckRollup),
364
+ };
365
+ }
366
+
367
+ /**
368
+ * Disarm GitHub native auto-merge on a PR (Story #5096).
369
+ *
370
+ * Lives here, beside the arm, because `lifecycle-lint`'s merge-lockout rule
371
+ * confines every `gh pr merge` invocation to this module: auto-merge
372
+ * enablement — and therefore its reversal — must flow through the Story close
373
+ * path rather than being spelled out wherever a caller happens to need it.
374
+ * The merge wait imports this rather than shelling out itself.
375
+ *
376
+ * Best-effort by contract: the caller blocks the Story either way, and a
377
+ * failed disarm is reported rather than thrown, because the one thing it
378
+ * cannot do is stop GitHub from landing the PR.
379
+ *
380
+ * @returns {Promise<boolean>} whether the disarm actually took.
381
+ */
382
+ export async function disarmAutoMerge({ prNumber, gh, progress }) {
383
+ try {
384
+ await (gh ?? defaultGh).pr.merge(String(prNumber), ['--disable-auto']);
385
+ progress?.(
386
+ 'CONFIRM',
387
+ `🔓 Auto-merge DISARMED on PR #${prNumber} — the PR stays open and hand-mergeable.`,
388
+ );
389
+ return true;
390
+ } catch (err) {
391
+ progress?.(
392
+ 'CONFIRM',
393
+ `⚠️ Could not disarm auto-merge on PR #${prNumber} (${err?.message ?? err}) — ` +
394
+ 'GitHub may still land it when the required checks pass. Disarm by hand.',
395
+ );
396
+ return false;
397
+ }
398
+ }
399
+
400
+ async function evaluateAdvisoryGate({
401
+ prNumber,
402
+ gh,
403
+ blockOnAdvisoryFailure,
404
+ advisoryAllowlist,
405
+ readPrWaitProbeFn,
406
+ progress,
407
+ }) {
408
+ if (!blockOnAdvisoryFailure) return { blocked: false };
409
+ let probe;
410
+ try {
411
+ probe = await readPrWaitProbeFn({ prNumber, gh });
412
+ } catch (err) {
413
+ progress?.(
414
+ 'PR',
415
+ `⚠️ Advisory-gate probe failed (${err?.message ?? err}) — arming anyway.`,
416
+ );
417
+ return { blocked: false };
418
+ }
419
+ if (probe?.error) {
420
+ progress?.(
421
+ 'PR',
422
+ `⚠️ Advisory-gate probe unavailable (${probe.error}) — arming anyway.`,
423
+ );
424
+ return { blocked: false };
425
+ }
426
+ if (!advisoryCheckFailedBlocksArm(probe, advisoryAllowlist)) {
427
+ return { blocked: false };
428
+ }
429
+ const blockingRuns = selectBlockingRedRuns(
430
+ probe.redHeadRuns,
431
+ advisoryAllowlist,
432
+ );
433
+ return {
434
+ blocked: true,
435
+ blockingRuns,
436
+ reason: formatAdvisoryGateReason(blockingRuns),
437
+ };
438
+ }
439
+
324
440
  /**
325
441
  * Dispatch auto-merge enablement based on `--no-auto-merge`, an
326
442
  * unparseable PR number, or a `gh` failure. Returns the structured
@@ -347,8 +463,11 @@ export async function runAutoMergePhase({
347
463
  prUrl,
348
464
  noAutoMerge,
349
465
  autoMergePolicy = 'trust-ci',
466
+ blockOnAdvisoryFailure = true,
467
+ advisoryAllowlist = [],
350
468
  gh,
351
469
  progress,
470
+ readPrWaitProbeFn = readAdvisoryProbe,
352
471
  }) {
353
472
  if (noAutoMerge) {
354
473
  progress('PR', '⏭ Auto-merge disabled (--no-auto-merge).');
@@ -381,6 +500,32 @@ export async function runAutoMergePhase({
381
500
  autoMergeReason: 'pr-number-unparseable',
382
501
  };
383
502
  }
503
+ // Story #5096 — refuse to arm over a genuinely red ADVISORY check. This
504
+ // gate covers the narrow case where the gate is ALREADY red at close time
505
+ // (a re-close, or a gate carried over from an earlier push); the merge
506
+ // wait owns the common case, where the gate reddens after arming.
507
+ const advisory = await evaluateAdvisoryGate({
508
+ prNumber,
509
+ gh,
510
+ blockOnAdvisoryFailure,
511
+ advisoryAllowlist,
512
+ readPrWaitProbeFn,
513
+ progress,
514
+ });
515
+ if (advisory.blocked) {
516
+ progress(
517
+ 'PR',
518
+ `🛑 Auto-merge NOT armed on PR #${prNumber}: ${advisory.reason}`,
519
+ );
520
+ return {
521
+ autoMergeEnabled: false,
522
+ autoMergeReason: 'advisory-gate-red',
523
+ advisoryGate: {
524
+ blockingRuns: advisory.blockingRuns,
525
+ reason: advisory.reason,
526
+ },
527
+ };
528
+ }
384
529
  const result = await enableAutoMergeWith({ cwd, prNumber, gh });
385
530
  if (result.enabled) {
386
531
  if (result.directMerged) {