mandrel 2.53.0 → 2.55.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 (114) hide show
  1. package/.agents/agents/story-worker.md +24 -23
  2. package/.agents/audit-checklists/accessibility.md +0 -3
  3. package/.agents/audit-checklists/mobile.md +0 -4
  4. package/.agents/docs/agentrc-reference.json +4 -2
  5. package/.agents/docs/configuration.md +2 -0
  6. package/.agents/schemas/agentrc.schema.json +15 -1
  7. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  8. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  9. package/.agents/scripts/audit-to-stories.js +158 -7
  10. package/.agents/scripts/check-audit-attribution.js +119 -62
  11. package/.agents/scripts/check-test-portability.js +512 -0
  12. package/.agents/scripts/coverage-capture.js +17 -10
  13. package/.agents/scripts/evidence-gate.js +31 -4
  14. package/.agents/scripts/generate-workflows-doc.js +65 -14
  15. package/.agents/scripts/git-cleanup.js +4 -0
  16. package/.agents/scripts/lib/ITicketingProvider.js +78 -0
  17. package/.agents/scripts/lib/audit-advisories.js +195 -0
  18. package/.agents/scripts/lib/audit-attribution.js +22 -0
  19. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +68 -5
  20. package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
  21. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +60 -114
  22. package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
  23. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
  24. package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
  25. package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
  26. package/.agents/scripts/lib/cli-args.js +26 -0
  27. package/.agents/scripts/lib/close-validation/gates.js +113 -7
  28. package/.agents/scripts/lib/close-validation/process.js +7 -3
  29. package/.agents/scripts/lib/close-validation/runner.js +62 -11
  30. package/.agents/scripts/lib/config/ci.js +28 -9
  31. package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
  32. package/.agents/scripts/lib/config-settings-schema.js +19 -1
  33. package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
  34. package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
  35. package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
  36. package/.agents/scripts/lib/coverage-capture.js +77 -3
  37. package/.agents/scripts/lib/findings/route-finding.js +4 -2
  38. package/.agents/scripts/lib/full-suite-lock.js +232 -6
  39. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  40. package/.agents/scripts/lib/git/sync-from-base.js +130 -13
  41. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  42. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
  43. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
  44. package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
  45. package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
  46. package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
  47. package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
  48. package/.agents/scripts/lib/orchestration/epic-rollup.js +241 -84
  49. package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
  50. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
  51. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
  52. package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
  53. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
  54. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
  55. package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
  56. package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
  57. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
  58. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
  59. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +24 -1
  60. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
  61. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
  62. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +119 -6
  63. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
  64. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
  65. package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
  66. package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
  67. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
  68. package/.agents/scripts/lib/orchestration/run-epilogue.js +59 -38
  69. package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
  70. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
  71. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
  72. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
  73. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
  74. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
  75. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
  76. package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
  77. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
  78. package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
  79. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +70 -6
  80. package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
  81. package/.agents/scripts/lib/pinned-override-notes.js +41 -53
  82. package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
  83. package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
  84. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
  85. package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
  86. package/.agents/scripts/lib/test-temp.js +167 -30
  87. package/.agents/scripts/lib/validation-evidence.js +37 -0
  88. package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
  89. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  90. package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
  91. package/.agents/scripts/merge-baseline.js +175 -21
  92. package/.agents/scripts/providers/github/errors.js +22 -1
  93. package/.agents/scripts/providers/github/issues.js +106 -1
  94. package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
  95. package/.agents/scripts/providers/github.js +6 -0
  96. package/.agents/scripts/resolve-stories.js +44 -34
  97. package/.agents/scripts/single-story-close.js +5 -0
  98. package/.agents/scripts/stories-wave-tick.js +37 -13
  99. package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
  100. package/.agents/workflows/audit-accessibility.md +16 -31
  101. package/.agents/workflows/audit-mobile.md +20 -37
  102. package/.agents/workflows/git-cleanup.md +17 -3
  103. package/.agents/workflows/helpers/audit-lens-core.md +45 -0
  104. package/.agents/workflows/helpers/deliver-digest.md +7 -6
  105. package/.agents/workflows/helpers/deliver-reference.md +40 -16
  106. package/.agents/workflows/helpers/deliver-story-reference.md +7 -4
  107. package/.agents/workflows/helpers/deliver-story.md +15 -12
  108. package/.agents/workflows/helpers/plan-reference.md +8 -1
  109. package/.agents/workflows/mandrel-plan.md +4 -7
  110. package/.agents/workflows/memory-consolidate.md +14 -9
  111. package/docs/CHANGELOG.md +34 -0
  112. package/lib/cli/registry.js +64 -21
  113. package/lib/cli/sync.js +27 -2
  114. package/package.json +7 -4
@@ -105,6 +105,8 @@ import {
105
105
  import { emitMergeUnlanded as defaultEmitMergeUnlanded } from '../../lifecycle/emit-merge-unlanded.js';
106
106
  import { classifyMergeBlock as defaultClassifyMergeBlock } from '../../merge-block-class.js';
107
107
  import {
108
+ ADVISORY_GATE_INCONCLUSIVE_CLASS,
109
+ ADVISORY_GATE_RED_CLASS,
108
110
  DEFAULT_INTERVAL_SECONDS,
109
111
  DEFAULT_MAX_BUDGET_SECONDS,
110
112
  decideAdvisoryGateBlock,
@@ -113,6 +115,9 @@ import {
113
115
  deriveRedHeadRuns,
114
116
  deriveRequiredRunEvidence,
115
117
  MERGE_WAIT_GH_TIMEOUT_MS,
118
+ parseWorkflowRunId,
119
+ readRunSummary,
120
+ resolveAdvisoryGateVerdict,
116
121
  } from '../../merge-poll.js';
117
122
  import { NEXT_COMMANDS } from '../../story-deliver-terminal.js';
118
123
  import {
@@ -209,6 +214,26 @@ function withGhTimeout(promise, timeoutMs, label) {
209
214
  return bounded;
210
215
  }
211
216
 
217
+ /**
218
+ * One string field off a `gh pr view` payload, or `absent` when the API did
219
+ * not return one. Deduplicated out of the probe below (Story #5266): six
220
+ * identical `typeof x === 'string'` ternaries put that one function over the
221
+ * CRAP ratchet the moment a seventh field was needed.
222
+ *
223
+ * An empty string counts as absent — `gh` returns `""` for a field it cannot
224
+ * read, and every caller treats that exactly as "not there".
225
+ *
226
+ * @param {unknown} value
227
+ * @param {null|undefined} [absent] What to report when the field is missing.
228
+ * `null` for the three fields the poll loop compares against null; the
229
+ * `undefined` default for the ones whose absence must not shadow a
230
+ * downstream default.
231
+ * @returns {string|null|undefined}
232
+ */
233
+ function readString(value, absent = undefined) {
234
+ return typeof value === 'string' && value ? value : absent;
235
+ }
236
+
212
237
  /**
213
238
  * One probe per poll iteration, carrying every field the loop and the
214
239
  * terminal classifier need: merge state, the checks rollup, the merge-state
@@ -238,22 +263,19 @@ export async function readPrWaitProbe({
238
263
  'mergeStateStatus',
239
264
  'reviewDecision',
240
265
  'statusCheckRollup',
266
+ // Story #5266 — the head the red advisory runs belong to, so their
267
+ // check-run output can be read back and classified.
268
+ 'headRefOid',
241
269
  ]),
242
270
  ghTimeoutMs,
243
271
  `gh pr view ${prNumber}`,
244
272
  );
245
273
  return {
246
- state: typeof view?.state === 'string' ? view.state : null,
247
- mergedAt: typeof view?.mergedAt === 'string' ? view.mergedAt : null,
248
- createdAt: typeof view?.createdAt === 'string' ? view.createdAt : null,
249
- mergeStateStatus:
250
- typeof view?.mergeStateStatus === 'string'
251
- ? view.mergeStateStatus
252
- : undefined,
253
- reviewDecision:
254
- typeof view?.reviewDecision === 'string'
255
- ? view.reviewDecision
256
- : undefined,
274
+ state: readString(view?.state, null),
275
+ mergedAt: readString(view?.mergedAt, null),
276
+ createdAt: readString(view?.createdAt, null),
277
+ mergeStateStatus: readString(view?.mergeStateStatus),
278
+ reviewDecision: readString(view?.reviewDecision),
257
279
  checksStatus: deriveChecksStatus(view?.statusCheckRollup),
258
280
  // Head-anchored per-run evidence (Story #4695): distinguishes a
259
281
  // genuinely red required run from the superseded / still-pending noise
@@ -264,6 +286,7 @@ export async function readPrWaitProbe({
264
286
  // verdict can name the offending job and match the allowlist. Same
265
287
  // red-ness test as `requiredRunEvidence`, so the two cannot disagree.
266
288
  redHeadRuns: deriveRedHeadRuns(view?.statusCheckRollup),
289
+ headSha: readString(view?.headRefOid),
267
290
  };
268
291
  } catch (err) {
269
292
  return {
@@ -373,6 +396,83 @@ export function resolveBudgetAnchorMs({ createdAt, fallbackMs }) {
373
396
  return Number.isFinite(parsed) ? parsed : fallbackMs;
374
397
  }
375
398
 
399
+ /**
400
+ * The advisory-gate half of {@link unlandedRemedy} (Story #5279).
401
+ *
402
+ * Both advisory classes used to fall through to the generic remedy, which
403
+ * opens by telling the operator to resolve "branch protection, required
404
+ * checks, or a manual merge" — a diagnosis of a fault that provably does not
405
+ * exist here. An advisory gate blocks precisely BECAUSE GitHub reports the PR
406
+ * mergeable (`mergeStateStatus=UNSTABLE`) over a NON-required red check, so
407
+ * close refused to let native auto-merge land it. Saying so is the paragraph
408
+ * that stops the operator hunting a protection rule that is working fine.
409
+ *
410
+ * The two classes then part on what the evidence authorises. `inconclusive`
411
+ * is a job that failed WITHOUT FINISHING and reported no violation — nothing
412
+ * says the change is bad — so the proportionate act is a re-run, named here
413
+ * as `--rerun-advisory`; the permanent global exemption is deliberately
414
+ * mentioned last. `advisory-gate-red` reported a real violation, so the
415
+ * change is implicated and landing over it is a deliberate override.
416
+ *
417
+ * @param {{ storyId: number, blockClass: string }} args
418
+ * @returns {string}
419
+ */
420
+ function advisoryGateRemedy({ storyId, blockClass }) {
421
+ const mergeable =
422
+ 'GitHub reports this PR **mergeable regardless** ' +
423
+ '(`mergeStateStatus=UNSTABLE`) — the check that blocked is **advisory** ' +
424
+ '(non-required), so there is nothing wrong with branch protection or the ' +
425
+ 'required checks. Close refused to let native auto-merge land the PR ' +
426
+ 'over the failure, and disarmed it.';
427
+ const act =
428
+ blockClass === ADVISORY_GATE_INCONCLUSIVE_CLASS
429
+ ? 'The job **failed without finishing** and reported no violation, so ' +
430
+ 'nothing here says the change is bad. Re-run it — ' +
431
+ '`--rerun-advisory <n>` on this command, or ' +
432
+ '`delivery.ci.rerunAdvisory` — rather than granting the permanent ' +
433
+ 'global exemption `delivery.ci.advisoryAllowlist` is. Merging by ' +
434
+ 'hand also lands it.'
435
+ : 'The job reported a real violation, so the change **is** implicated. ' +
436
+ 'Fix it and push a new head, merge by hand to land over it ' +
437
+ 'deliberately, re-run the job (`--rerun-advisory <n>`, or ' +
438
+ '`delivery.ci.rerunAdvisory`) if you believe it flaked, or exempt ' +
439
+ 'the job via `delivery.ci.advisoryAllowlist`.';
440
+ return (
441
+ `${mergeable}\n\n${act}\n\nThen resume the land:\n\n` +
442
+ `\`\`\`bash\n${NEXT_COMMANDS.resumeLand(storyId)}\n\`\`\``
443
+ );
444
+ }
445
+
446
+ /**
447
+ * The class-specific remediation paragraph of the unlanded friction comment.
448
+ * Split out of {@link formatUnlandedFriction} so each class's wording is one
449
+ * named branch rather than a nested ternary.
450
+ *
451
+ * @param {{ storyId: number, prNumber: number|null, blockClass: string }} args
452
+ * @returns {string}
453
+ */
454
+ function unlandedRemedy({ storyId, prNumber, blockClass }) {
455
+ if (blockClass === 'checks-failed') {
456
+ return (
457
+ `A required check is **red**. Fix the failure and push a new commit on \`story-${storyId}\`; ` +
458
+ `the red disarms auto-merge, and only a green on a new head SHA re-arms it — ` +
459
+ `re-running the failed job is forbidden. Watch the checks with:\n\n` +
460
+ `\`\`\`bash\n${NEXT_COMMANDS.watchCi(storyId, prNumber)}\n\`\`\``
461
+ );
462
+ }
463
+ if (
464
+ blockClass === ADVISORY_GATE_INCONCLUSIVE_CLASS ||
465
+ blockClass === ADVISORY_GATE_RED_CLASS
466
+ ) {
467
+ return advisoryGateRemedy({ storyId, blockClass });
468
+ }
469
+ return (
470
+ `Resolve the underlying condition (branch protection, required checks, ` +
471
+ `or a manual merge), then resume the land:\n\n` +
472
+ `\`\`\`bash\n${NEXT_COMMANDS.resumeLand(storyId)}\n\`\`\``
473
+ );
474
+ }
475
+
376
476
  /**
377
477
  * Format the `friction` comment body posted alongside the `agent::blocked`
378
478
  * transition when a landing attempt gives up without a confirmed merge.
@@ -389,15 +489,7 @@ function formatUnlandedFriction({
389
489
  Number.isInteger(prNumber) && prNumber > 0
390
490
  ? `PR #${prNumber}${prUrl ? ` (${prUrl})` : ''}`
391
491
  : (prUrl ?? 'the PR');
392
- const remedy =
393
- blockClass === 'checks-failed'
394
- ? `A required check is **red**. Fix the failure and push a new commit on \`story-${storyId}\`; ` +
395
- `the red disarms auto-merge, and only a green on a new head SHA re-arms it — ` +
396
- `re-running the failed job is forbidden. Watch the checks with:\n\n` +
397
- `\`\`\`bash\n${NEXT_COMMANDS.watchCi(storyId, prNumber)}\n\`\`\``
398
- : `Resolve the underlying condition (branch protection, required checks, ` +
399
- `or a manual merge), then resume the land:\n\n` +
400
- `\`\`\`bash\n${NEXT_COMMANDS.resumeLand(storyId)}\n\`\`\``;
492
+ const remedy = unlandedRemedy({ storyId, prNumber, blockClass });
401
493
  return (
402
494
  `### close-and-land: merge did not land\n\n` +
403
495
  `Story #${storyId}: the close polled ${prLabel} for merge confirmation and ` +
@@ -545,11 +637,179 @@ async function blockOnFlipFailed({
545
637
  }
546
638
 
547
639
  /**
548
- * Classify the unlanded merge, emit `merge.unlanded`, post a `friction`
549
- * comment, and transition the Story to `agent::blocked`. Every side effect
550
- * is best-effort logged rather than thrown — the caller owns surfacing the
551
- * non-zero exit once this returns.
640
+ * Story #5266 — the per-invocation advisory rerun allowance.
641
+ *
642
+ * `--rerun-advisory <n>` wins over `delivery.ci.rerunAdvisory` on exactly the
643
+ * precedence `--max-wait-seconds` already uses. Both default to **0**: a
644
+ * rerun spends CI minutes and mutates GitHub state, so close does neither
645
+ * unasked. A non-integer or negative override is not an instruction to guess
646
+ * — it degrades to the config value.
647
+ *
648
+ * @param {object|null} config Resolved config (or any `delivery.ci` bag).
649
+ * @param {number} [override] The `--rerun-advisory` value, when supplied.
650
+ * @returns {number} allowance ≥ 0
651
+ */
652
+ export function resolveAdvisoryRerunAllowance(config, override) {
653
+ if (Number.isInteger(override) && override >= 0) return override;
654
+ return getCiDelivery(config).rerunAdvisory;
655
+ }
656
+
657
+ /**
658
+ * Identify ONE observation of a red run: the job, the workflow run behind it,
659
+ * and when that run finished. A rerun changes `completedAt` (and eventually
660
+ * the conclusion), so this is what lets the wait tell a re-run's verdict apart
661
+ * from the stale pre-rerun snapshot it will keep seeing for a poll or two.
662
+ *
663
+ * @param {{name?: string|null, runId?: number, completedAt?: string}} run
664
+ * @returns {string}
665
+ */
666
+ function advisoryRunSignature(run) {
667
+ return `${run?.name ?? '(unnamed)'}@${run?.runId ?? 'no-run'}#${run?.completedAt ?? 'no-stamp'}`;
668
+ }
669
+
670
+ /**
671
+ * Story #5266 — read each red advisory run's own account of WHY it failed.
672
+ *
673
+ * `gh pr view --json statusCheckRollup` has a fixed projection that carries no
674
+ * output text for a CheckRun, so on the rollup alone every red advisory run
675
+ * looks identical — which is the defect. The check-runs API for the head SHA
676
+ * carries `output.title` / `output.summary`, and ONE call for the whole head
677
+ * is enough to classify every red run on it.
678
+ *
679
+ * Called only on the block path (never per poll), and **fails open**: any
680
+ * error, timeout, or missing head SHA returns the runs unchanged, which
681
+ * classifies them as `advisory-gate-red` — the pre-#5266 verdict.
682
+ *
683
+ * @returns {Promise<Array<object>>} the runs, enriched where output was found
684
+ */
685
+ async function enrichRedRunsWithOutput({
686
+ runs,
687
+ headSha,
688
+ gh,
689
+ ghTimeoutMs,
690
+ progress,
691
+ }) {
692
+ if (!Array.isArray(runs) || runs.length === 0 || !headSha) return runs ?? [];
693
+ try {
694
+ const raw = await withGhTimeout(
695
+ (gh ?? defaultGh).api({
696
+ endpoint: `/repos/{owner}/{repo}/commits/${headSha}/check-runs?per_page=100`,
697
+ }),
698
+ ghTimeoutMs,
699
+ `gh api check-runs ${headSha}`,
700
+ );
701
+ const payload = JSON.parse(raw?.stdout ?? '{}');
702
+ const byName = new Map();
703
+ for (const checkRun of payload?.check_runs ?? []) {
704
+ if (typeof checkRun?.name === 'string' && checkRun.name) {
705
+ byName.set(checkRun.name, checkRun);
706
+ }
707
+ }
708
+ return runs.map((run) => {
709
+ const summary = readRunSummary(run.name ? byName.get(run.name) : null);
710
+ return summary ? { ...run, summary } : run;
711
+ });
712
+ } catch (err) {
713
+ progress?.(
714
+ 'CONFIRM',
715
+ `⚠️ Could not read the advisory check output (${err?.message ?? err}) — ` +
716
+ 'classifying from the rollup alone.',
717
+ );
718
+ return runs;
719
+ }
720
+ }
721
+
722
+ /**
723
+ * Story #5266 — re-run the failed advisory workflow run(s), once, within the
724
+ * caller's remaining allowance.
725
+ *
726
+ * Requests `rerun-failed-jobs` per distinct workflow run rather than per job:
727
+ * one advisory workflow commonly fans out, and re-running the whole failed set
728
+ * is both cheaper in API calls and what an operator means by "re-run it".
729
+ *
730
+ * @returns {Promise<boolean>} whether every rerun request succeeded. `false`
731
+ * (including "no run id to re-run") leaves the caller on the block path,
732
+ * because an allowance that cannot be spent must not silently suppress the
733
+ * gate.
552
734
  */
735
+ async function rerunAdvisoryRuns({ blockingRuns, gh, ghTimeoutMs, progress }) {
736
+ const runIds = [
737
+ ...new Set(
738
+ blockingRuns
739
+ .map((run) => run?.runId ?? parseWorkflowRunId(run?.detailsUrl))
740
+ .filter((id) => Number.isInteger(id) && id > 0),
741
+ ),
742
+ ];
743
+ if (runIds.length === 0) {
744
+ progress?.(
745
+ 'CONFIRM',
746
+ '⚠️ Advisory rerun requested but no workflow run id is readable on the ' +
747
+ 'red run(s) — blocking instead.',
748
+ );
749
+ return false;
750
+ }
751
+ for (const runId of runIds) {
752
+ try {
753
+ await withGhTimeout(
754
+ (gh ?? defaultGh).api({
755
+ method: 'POST',
756
+ endpoint: `/repos/{owner}/{repo}/actions/runs/${runId}/rerun-failed-jobs`,
757
+ }),
758
+ ghTimeoutMs,
759
+ `gh api rerun-failed-jobs ${runId}`,
760
+ );
761
+ } catch (err) {
762
+ progress?.(
763
+ 'CONFIRM',
764
+ `⚠️ Advisory rerun of workflow run ${runId} failed (${err?.message ?? err}) — blocking instead.`,
765
+ );
766
+ return false;
767
+ }
768
+ }
769
+ progress?.(
770
+ 'CONFIRM',
771
+ `🔁 Re-ran ${runIds.length} failed advisory workflow run(s) [${runIds.join(', ')}] — ` +
772
+ 'the merge wait keeps polling inside its existing budget.',
773
+ );
774
+ return true;
775
+ }
776
+
777
+ /**
778
+ * Spend one unit of the rerun allowance, if there is one and the rerun takes.
779
+ * Records the observation signature of every run it re-ran, so the stale
780
+ * pre-rerun snapshot the next poll reads does not re-block on the same job.
781
+ *
782
+ * Deliberately does NOT disarm first, unlike the block path: the whole point
783
+ * of a rerun is that a green re-run lands the PR on its own. The cost is an
784
+ * armed window in which GitHub could land the PR over the still-red advisory
785
+ * if the required contexts go green before the re-run reports — which is
786
+ * exactly what opting in to `--rerun-advisory` buys and accepts. At the
787
+ * default 0 there is no such window.
788
+ *
789
+ * @returns {Promise<boolean>} `true` when the caller should keep polling.
790
+ */
791
+ async function maybeRerunAdvisory({
792
+ rerunState,
793
+ blockingRuns,
794
+ gh,
795
+ ghTimeoutMs,
796
+ progress,
797
+ }) {
798
+ if (rerunState.remaining <= 0) return false;
799
+ const rerun = await rerunAdvisoryRuns({
800
+ blockingRuns,
801
+ gh,
802
+ ghTimeoutMs,
803
+ progress,
804
+ });
805
+ if (!rerun) return false;
806
+ rerunState.remaining -= 1;
807
+ for (const run of blockingRuns) {
808
+ rerunState.issued.add(advisoryRunSignature(run));
809
+ }
810
+ return true;
811
+ }
812
+
553
813
  /**
554
814
  * Story #5096 — resolve the advisory-gate terminal for one poll.
555
815
  *
@@ -561,14 +821,22 @@ async function blockOnFlipFailed({
561
821
  *
562
822
  * Disarms BEFORE returning the terminal: an armed PR can merge out from under
563
823
  * the block the caller is about to record.
824
+ *
825
+ * Story #5266 threads three more steps through the same single assignment:
826
+ * runs this invocation already re-ran (and has no fresh verdict for) are
827
+ * skipped rather than re-blocked; the survivors are enriched with their own
828
+ * check-run output so the class can be `advisory-gate-inconclusive`; and a
829
+ * remaining rerun allowance is spent before any block is recorded.
564
830
  */
565
831
  async function resolveAdvisoryUnlanded({
566
832
  unlanded,
567
833
  probe,
568
834
  blockOnAdvisoryFailure,
569
835
  advisoryAllowlist,
836
+ rerunState,
570
837
  prNumber,
571
838
  gh,
839
+ ghTimeoutMs,
572
840
  progress,
573
841
  disarmAutoMergeFn,
574
842
  elapsedSeconds,
@@ -580,16 +848,51 @@ async function resolveAdvisoryUnlanded({
580
848
  advisoryAllowlist,
581
849
  });
582
850
  if (!advisory) return null;
583
- progress?.('CONFIRM', `🛑 PR #${prNumber}: ${advisory.reason}`);
851
+ // Every blocking run is one this invocation already re-ran and has not seen
852
+ // a fresh verdict for yet (Story #5266) — keep polling rather than blocking
853
+ // on the snapshot the rerun was meant to replace.
854
+ const pending = advisory.blockingRuns.filter(
855
+ (run) => !rerunState.issued.has(advisoryRunSignature(run)),
856
+ );
857
+ if (pending.length === 0) return null;
858
+ const blockingRuns = await enrichRedRunsWithOutput({
859
+ runs: pending,
860
+ headSha: probe?.headSha,
861
+ gh,
862
+ ghTimeoutMs,
863
+ progress,
864
+ });
865
+ if (
866
+ await maybeRerunAdvisory({
867
+ rerunState,
868
+ blockingRuns,
869
+ gh,
870
+ ghTimeoutMs,
871
+ progress,
872
+ })
873
+ ) {
874
+ return null;
875
+ }
876
+ const verdict = resolveAdvisoryGateVerdict({
877
+ blockingRuns,
878
+ rerunAllowance: rerunState.allowance,
879
+ });
880
+ progress?.('CONFIRM', `🛑 PR #${prNumber}: ${verdict.reason}`);
584
881
  await disarmAutoMergeFn({ prNumber, gh, progress });
585
882
  return {
586
883
  prProbe: probe,
587
884
  budget: { exhausted: false, elapsedSeconds },
588
- blockClassOverride: 'advisory-gate-red',
589
- reasonOverride: advisory.reason,
885
+ blockClassOverride: verdict.blockClass,
886
+ reasonOverride: verdict.reason,
590
887
  };
591
888
  }
592
889
 
890
+ /**
891
+ * Classify the unlanded merge, emit `merge.unlanded`, post a `friction`
892
+ * comment, and transition the Story to `agent::blocked`. Every side effect
893
+ * is best-effort logged rather than thrown — the caller owns surfacing the
894
+ * non-zero exit once this returns.
895
+ */
593
896
  async function blockOnUnlanded({
594
897
  storyId,
595
898
  prNumber,
@@ -853,6 +1156,8 @@ async function onMergeObserved({
853
1156
  * `--merge-watch-mode` override (Story #4949); wins over
854
1157
  * `delivery.mergeWatch.mode`.
855
1158
  * @param {(tag: string, msg: string) => void} [args.progress]
1159
+ * @param {number} [args.rerunAdvisory] `--rerun-advisory <n>` — the
1160
+ * per-invocation override of `delivery.ci.rerunAdvisory` (both default 0).
856
1161
  * @param {object} [args.injectedGh]
857
1162
  * @param {Function} [args.injectedNotify]
858
1163
  * @param {Function} [args.confirmStoryMergedFn] Test seam — defaults to the
@@ -886,6 +1191,7 @@ export async function runConfirmMergePhase({
886
1191
  config,
887
1192
  maxWaitSeconds: maxWaitSecondsOverride,
888
1193
  mergeWatchMode: mergeWatchModeOverride,
1194
+ rerunAdvisory: rerunAdvisoryOverride,
889
1195
  progress,
890
1196
  injectedGh,
891
1197
  injectedNotify,
@@ -923,9 +1229,12 @@ export async function runConfirmMergePhase({
923
1229
  // Story #5096 — the arm phase already refused over a red advisory gate;
924
1230
  // carry its verdict through instead of letting the classifier read this
925
1231
  // as a generic `arm-failure`.
1232
+ // Story #5266 — carry the arm phase's CLASS too: a pre-arm refusal over
1233
+ // a scan that never finished is `advisory-gate-inconclusive`, and
1234
+ // hard-coding the red class here would relabel it at the terminal.
926
1235
  ...(autoMergeReason === 'advisory-gate-red'
927
1236
  ? {
928
- blockClassOverride: 'advisory-gate-red',
1237
+ blockClassOverride: advisoryGate?.blockClass ?? 'advisory-gate-red',
929
1238
  reasonOverride: advisoryGate?.reason,
930
1239
  }
931
1240
  : {}),
@@ -945,6 +1254,18 @@ export async function runConfirmMergePhase({
945
1254
  );
946
1255
  // Story #5096 — the advisory-gate knobs, read once for the whole wait.
947
1256
  const { blockOnAdvisoryFailure, advisoryAllowlist } = getCiDelivery(config);
1257
+ // Story #5266 — the rerun allowance and its ledger, spent across the whole
1258
+ // wait rather than per poll, so `n` bounds the CI minutes this invocation
1259
+ // can cost no matter how many times the gate is observed red.
1260
+ const rerunAllowance = resolveAdvisoryRerunAllowance(
1261
+ config,
1262
+ rerunAdvisoryOverride,
1263
+ );
1264
+ const rerunState = {
1265
+ allowance: rerunAllowance,
1266
+ remaining: rerunAllowance,
1267
+ issued: new Set(),
1268
+ };
948
1269
  const intervalMs = intervalSeconds * 1000;
949
1270
  const startedAtMs = nowMsFn();
950
1271
  let anchorMs = startedAtMs;
@@ -1105,8 +1426,10 @@ export async function runConfirmMergePhase({
1105
1426
  probe,
1106
1427
  blockOnAdvisoryFailure,
1107
1428
  advisoryAllowlist,
1429
+ rerunState,
1108
1430
  prNumber,
1109
1431
  gh: injectedGh,
1432
+ ghTimeoutMs,
1110
1433
  progress,
1111
1434
  disarmAutoMergeFn,
1112
1435
  elapsedSeconds: Math.round(waitedMs / 1000),
@@ -36,6 +36,23 @@ function resolveFlag(paramValue, parsedValue) {
36
36
  return paramValue ?? parsedValue;
37
37
  }
38
38
 
39
+ /**
40
+ * An integer flag at or above `min`, or `undefined` — the shape BOTH numeric
41
+ * close flags share (Story #5266 deduplicated them): absence is preserved so
42
+ * the reader downstream applies its own config default, and a junk value is
43
+ * treated as absent rather than coerced into a bound nobody asked for.
44
+ *
45
+ * `min` is what separates them: `--max-wait-seconds 0` is a typo (a
46
+ * zero-second wait), while `--rerun-advisory 0` is the meaningful default.
47
+ *
48
+ * @param {unknown} value
49
+ * @param {number} min
50
+ * @returns {number|undefined}
51
+ */
52
+ function intAtLeast(value, min) {
53
+ return Number.isInteger(value) && value >= min ? value : undefined;
54
+ }
55
+
39
56
  /**
40
57
  * Resolve whether close lands the PR in-process (`waitForMerge`).
41
58
  *
@@ -139,8 +156,8 @@ function assertNoRetiredFlags(argv) {
139
156
  * (`waitForMergeExplicit` / `noWaitForMerge`) for the runner to resolve once
140
157
  * the config and the arm outcome exist.
141
158
  *
142
- * @param {{ storyIdParam, cwdParam, skipValidationParam, skipSyncParam, noAutoMergeParam, waitForMergeParam, noWaitForMergeParam, maxWaitSecondsParam, mergeWatchModeParam, overrideReviewBlockParam }} raw
143
- * @returns {{ storyId, cwd, skipValidation, skipSync, noAutoMerge, waitForMergeExplicit, noWaitForMerge, maxWaitSeconds, mergeWatchMode, overrideReviewBlock }}
159
+ * @param {{ storyIdParam, cwdParam, skipValidationParam, skipSyncParam, noAutoMergeParam, waitForMergeParam, noWaitForMergeParam, maxWaitSecondsParam, mergeWatchModeParam, rerunAdvisoryParam, overrideReviewBlockParam }} raw
160
+ * @returns {{ storyId, cwd, skipValidation, skipSync, noAutoMerge, waitForMergeExplicit, noWaitForMerge, maxWaitSeconds, mergeWatchMode, rerunAdvisory, overrideReviewBlock }}
144
161
  */
145
162
  export function parseCloseOptions({
146
163
  storyIdParam,
@@ -152,6 +169,7 @@ export function parseCloseOptions({
152
169
  noWaitForMergeParam,
153
170
  maxWaitSecondsParam,
154
171
  mergeWatchModeParam,
172
+ rerunAdvisoryParam,
155
173
  overrideReviewBlockParam,
156
174
  }) {
157
175
  // An injecting caller (`storyIdParam` supplied) is not reading argv at all,
@@ -178,6 +196,7 @@ export function parseCloseOptions({
178
196
  maxWaitSecondsParam,
179
197
  parsed.maxWaitSeconds,
180
198
  );
199
+ const rerunAdvisory = resolveFlag(rerunAdvisoryParam, parsed.rerunAdvisory);
181
200
  return {
182
201
  storyId: resolveFlag(storyIdParam, parsed.storyId),
183
202
  cwd: path.resolve(cwdParam ?? parsed.cwd ?? PROJECT_ROOT),
@@ -185,10 +204,7 @@ export function parseCloseOptions({
185
204
  // `delivery.mergeWatch.maxWaitSeconds`. A per-run override exists so a
186
205
  // headless caller with no host tool-invocation ceiling can keep
187
206
  // single-block semantics without editing the consumer's config.
188
- maxWaitSeconds:
189
- Number.isInteger(maxWaitSeconds) && maxWaitSeconds > 0
190
- ? maxWaitSeconds
191
- : undefined,
207
+ maxWaitSeconds: intAtLeast(maxWaitSeconds, 1),
192
208
  // `undefined` when unsupplied — the merge wait then reads
193
209
  // `delivery.mergeWatch.mode`. The two merge-watch flags stay composable and
194
210
  // mode-agnostic: `--merge-watch-mode async` picks the posture, and an
@@ -196,6 +212,11 @@ export function parseCloseOptions({
196
212
  mergeWatchMode: parseMergeWatchMode(
197
213
  resolveFlag(mergeWatchModeParam, parsed.mergeWatchMode),
198
214
  ),
215
+ // Story #5266 — `undefined` when unsupplied, so the merge wait reads
216
+ // `delivery.ci.rerunAdvisory` (default 0). An explicit 0 is preserved as
217
+ // an explicit 0: it means "spend nothing", which is also the default, but
218
+ // an operator who typed it must not have it read as absent.
219
+ rerunAdvisory: intAtLeast(rerunAdvisory, 0),
199
220
  skipValidation: !!resolveFlag(skipValidationParam, parsed.skipValidation),
200
221
  skipSync: !!resolveFlag(skipSyncParam, parsed.skipSync),
201
222
  noAutoMerge: !!resolveFlag(noAutoMergeParam, parsed.noAutoMerge),