mandrel 2.54.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 +233 -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 +63 -0
  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 +30 -0
  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 +35 -14
  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 +7 -0
  109. package/.agents/workflows/mandrel-plan.md +4 -7
  110. package/.agents/workflows/memory-consolidate.md +14 -9
  111. package/docs/CHANGELOG.md +27 -0
  112. package/lib/cli/registry.js +64 -21
  113. package/lib/cli/sync.js +27 -2
  114. package/package.json +7 -4
@@ -13,8 +13,18 @@
13
13
  * On a merge conflict the Story is transitioned to `agent::blocked` via
14
14
  * `handleSyncFailure` and the caller throws — the operator resolves in
15
15
  * the worktree and re-runs.
16
+ *
17
+ * Story #5267: a sync that lands tracked content also spends the worker's
18
+ * pre-push credit, because that credit is keyed on the tree. The phase says
19
+ * so out loud — see `buildStampInvalidatedWarning` — instead of leaving
20
+ * close's second full suite looking like a bug. Story #5278 splits the claim
21
+ * in two: gate evidence is spent by any tracked path, the capture stamp only
22
+ * by one under `crap.targetDirs`.
16
23
  */
17
24
 
25
+ import { getQuality } from '../../../config/quality.js';
26
+ import { resolveConfig } from '../../../config-resolver.js';
27
+ import { filterFilesUnderTargets } from '../../../coverage-capture.js';
18
28
  import { syncBranchFromBase } from '../../../git/sync-from-base.js';
19
29
  import { Logger } from '../../../Logger.js';
20
30
  import { AGENT_LABELS } from '../../../label-constants.js';
@@ -40,6 +50,7 @@ import {
40
50
  * storyId: number,
41
51
  * provider: object,
42
52
  * injectedSync?: typeof syncBranchFromBase,
53
+ * resolveConfigImpl?: typeof resolveConfig,
43
54
  * progress: (tag: string, msg: string) => void,
44
55
  * }} args
45
56
  */
@@ -52,6 +63,7 @@ export async function runBaseSyncPhase({
52
63
  storyId,
53
64
  provider,
54
65
  injectedSync,
66
+ resolveConfigImpl = resolveConfig,
55
67
  progress,
56
68
  }) {
57
69
  const syncCwd = worktreePath ?? cwd;
@@ -87,6 +99,95 @@ export async function runBaseSyncPhase({
87
99
  );
88
100
  }
89
101
  progress('SYNC', `✅ Synced from origin/${baseBranch} (${syncResult.kind}).`);
102
+ for (const line of buildStampInvalidatedWarning({
103
+ baseBranch,
104
+ result: syncResult,
105
+ targetDirs: resolveCrapTargetDirs(resolveConfigImpl, syncCwd),
106
+ })) {
107
+ progress('SYNC', line);
108
+ }
109
+ }
110
+
111
+ /**
112
+ * The CRAP scoring scope, or `[]` when it cannot be resolved. A `[]` makes
113
+ * the warning below fall back to naming the capture stamp unconditionally —
114
+ * the pre-#5278 wording — because an unresolvable scope is no evidence that
115
+ * the stamp survived.
116
+ *
117
+ * @param {typeof resolveConfig} resolveConfigImpl
118
+ * @param {string} cwd
119
+ * @returns {string[]}
120
+ */
121
+ function resolveCrapTargetDirs(resolveConfigImpl, cwd) {
122
+ try {
123
+ return getQuality(resolveConfigImpl({ cwd }))?.crap?.targetDirs ?? [];
124
+ } catch {
125
+ return [];
126
+ }
127
+ }
128
+
129
+ /**
130
+ * How many changed paths the warning names before it stops listing.
131
+ * Enough to recognise the change set; short enough that the warning still
132
+ * reads as a warning rather than as a diff.
133
+ */
134
+ const WARNED_PATH_LIMIT = 12;
135
+
136
+ /**
137
+ * The loud "your credit is spent" warning, or `[]` when the sync changed
138
+ * nothing (Story #5267, narrowed by #5278).
139
+ *
140
+ * The worker banks two kinds of credit before the push: gate evidence keyed
141
+ * on the tree (lint, typecheck) and one full-suite capture stamp keyed on the
142
+ * content of `crap.targetDirs`. This sync runs after that, and the two are
143
+ * spent on different conditions — which is why #5267's single blanket
144
+ * sentence was wrong half the time:
145
+ *
146
+ * - **Gate evidence** is spent by any tracked path at all, because the tree
147
+ * hash it is keyed on moves with the first byte.
148
+ * - **The capture stamp** is spent only when a merged path lands under
149
+ * `crap.targetDirs`. A sync that brings in docs, workflows or CI config
150
+ * leaves it perfectly valid, and announcing it as spent taught operators
151
+ * to expect a second full suite that close was never going to run.
152
+ *
153
+ * Quiet by construction on the outcome that cannot spend either: a
154
+ * `noop-already-current` sync never touched the tree. A content-changing
155
+ * fast-forward DOES warn — it moves the tree exactly as a merge commit does,
156
+ * and staying quiet there would be a lie of omission.
157
+ *
158
+ * Pure. Module-private: the phase is the seam tests drive it through
159
+ * (`injectedSync` + a `progress` spy), so it needs no export of its own.
160
+ *
161
+ * @param {{ baseBranch: string, result: { kind?: string, changedPaths?: string[] }, targetDirs?: string[] }} args
162
+ * @returns {string[]} Progress lines, in order. Empty when nothing changed.
163
+ */
164
+ function buildStampInvalidatedWarning({ baseBranch, result, targetDirs }) {
165
+ const changed = Array.isArray(result?.changedPaths)
166
+ ? result.changedPaths
167
+ : [];
168
+ if (changed.length === 0) return [];
169
+ const dirs = Array.isArray(targetDirs) ? targetDirs : [];
170
+ // An unresolvable scope (`[]`) cannot prove the stamp survived, so it fails
171
+ // closed to the unconditional wording.
172
+ const scored =
173
+ dirs.length === 0 ? changed : filterFilesUnderTargets(changed, dirs);
174
+ const shown = changed.slice(0, WARNED_PATH_LIMIT);
175
+ const overflow = changed.length - shown.length;
176
+ return [
177
+ `⚠️ BASE MOVED: the ${result?.kind ?? 'sync'} from origin/${baseBranch} ` +
178
+ `brought ${changed.length} tracked path(s) into this branch, so the tree ` +
179
+ `hash the pre-push lint/typecheck evidence was keyed on has changed. ` +
180
+ `That evidence cannot be credited; those gates re-run below.`,
181
+ scored.length > 0
182
+ ? `⚠️ The full-suite capture stamp is spent too: ${scored.length} of ` +
183
+ `those path(s) fall under the CRAP target dirs [${dirs.join(', ')}], ` +
184
+ `so the suite re-runs against the merged tree. This is expected, not a fault.`
185
+ : `⚠️ The full-suite capture stamp SURVIVES: no merged path falls under ` +
186
+ `the CRAP target dirs [${dirs.join(', ')}], so the coverage artifact still ` +
187
+ `describes this tree and the suite is not re-run.`,
188
+ ...shown.map((f) => `⚠️ ${f}`),
189
+ ...(overflow > 0 ? [`⚠️ …and ${overflow} more`] : []),
190
+ ];
90
191
  }
91
192
 
92
193
  /**
@@ -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),