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
@@ -71,7 +71,11 @@
71
71
  * (`emit-merge-unlanded.js`).
72
72
  */
73
73
 
74
- import { requiredCheckFailedBlocksMerge } from './merge-poll.js';
74
+ import {
75
+ ADVISORY_GATE_INCONCLUSIVE_CLASS,
76
+ ADVISORY_GATE_RED_CLASS,
77
+ requiredCheckFailedBlocksMerge,
78
+ } from './merge-poll.js';
75
79
 
76
80
  /**
77
81
  * Every class `classifyMergeBlock` can return. Order is the evaluation
@@ -102,7 +106,14 @@ export const BLOCK_CLASSES = Object.freeze([
102
106
  * (`mergeStateStatus: UNSTABLE`), which native auto-merge would land straight
103
107
  * past. It is emitted directly by the arm and merge-wait phases — the
104
108
  * 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
109
+ * the merge, which is the entire problem it names. Story #5266 added
110
+ * `advisory-gate-inconclusive` beside it, emitted directly by the same two
111
+ * phases and under the same discipline: the SAME observation (a red advisory
112
+ * run on an `UNSTABLE` PR) whose run never FINISHED — a scan or navigation
113
+ * timeout reporting no violation. It blocks exactly as `advisory-gate-red`
114
+ * does; it exists because the two authorise different remedies, and reporting
115
+ * a timed-out scan as a found violation pushes the operator toward a permanent
116
+ * allowlist exemption for a transient failure. It is deliberately NOT in
106
117
  * `BLOCK_CLASSES`, whose reachability invariant covers only what
107
118
  * `classifyMergeBlock` returns. (The Epic-era listeners that used to
108
119
  * emit it, AutomergePredicate and AutomergeArmer, are gone; the value stays
@@ -114,7 +125,11 @@ export const BLOCK_CLASSES = Object.freeze([
114
125
  export const MERGE_UNLANDED_BLOCK_CLASSES = Object.freeze([
115
126
  ...BLOCK_CLASSES,
116
127
  'predicate-refused',
117
- 'advisory-gate-red',
128
+ // Sourced from the constants the advisory gate itself decides with
129
+ // (Story #5266), so the attribution vocabulary cannot drift from the
130
+ // verdict that emits it.
131
+ ADVISORY_GATE_RED_CLASS,
132
+ ADVISORY_GATE_INCONCLUSIVE_CLASS,
118
133
  ]);
119
134
 
120
135
  const BLOCK_CLASS_SET = new Set(MERGE_UNLANDED_BLOCK_CLASSES);
@@ -113,6 +113,50 @@ export function deriveChecksStatus(statusCheckRollup) {
113
113
  * @param {Array<{status?: string, conclusion?: string, state?: string}>} statusCheckRollup
114
114
  * @returns {{ requiredRunFailed: boolean, requiredRunInFlight: boolean } | null}
115
115
  */
116
+ /**
117
+ * Pure: the uppercase conclusion of a check that GENUINELY concluded red, or
118
+ * `null` when it did not.
119
+ *
120
+ * Red means `FAILURE` / `ERROR` only — never `CANCELLED` / `TIMED_OUT` /
121
+ * `SKIPPED`, which are the superseded-push and sibling-invalidated runs a bare
122
+ * rollup read miscounts (the #4695 / #4710 trap). A CheckRun carries the
123
+ * verdict on `conclusion`; a legacy StatusContext carries it on `state`, so
124
+ * both are read and the one that is red is the one returned.
125
+ *
126
+ * Extracted (Story #5266) because {@link deriveRequiredRunEvidence} and
127
+ * {@link deriveRedHeadRuns} were carrying byte-identical copies of this test:
128
+ * two places that must agree about what "red" means, and nothing making them.
129
+ *
130
+ * @param {{ conclusion?: string, state?: string }} [check]
131
+ * @returns {string|null}
132
+ */
133
+ function redConclusionOf(check) {
134
+ const conclusion = String(check?.conclusion ?? '').toUpperCase();
135
+ if (conclusion === 'FAILURE' || conclusion === 'ERROR') return conclusion;
136
+ const state = String(check?.state ?? '').toUpperCase();
137
+ if (state === 'FAILURE' || state === 'ERROR') return state;
138
+ return null;
139
+ }
140
+
141
+ /**
142
+ * Pure: a check's display name — the CheckRun's `name`, falling back to a
143
+ * legacy StatusContext's `context`, and `null` when the projection carries
144
+ * neither.
145
+ *
146
+ * A run with no readable name can never match an allowlist entry, so it always
147
+ * blocks. That is the conservative direction for a gate whose whole purpose is
148
+ * to stop a silent landing.
149
+ *
150
+ * @param {{ name?: string, context?: string }} [check]
151
+ * @returns {string|null}
152
+ */
153
+ function readRunName(check) {
154
+ for (const value of [check?.name, check?.context]) {
155
+ if (typeof value === 'string' && value) return value;
156
+ }
157
+ return null;
158
+ }
159
+
116
160
  export function deriveRequiredRunEvidence(statusCheckRollup) {
117
161
  if (!Array.isArray(statusCheckRollup) || statusCheckRollup.length === 0) {
118
162
  return null;
@@ -120,7 +164,6 @@ export function deriveRequiredRunEvidence(statusCheckRollup) {
120
164
  let requiredRunFailed = false;
121
165
  let requiredRunInFlight = false;
122
166
  for (const check of statusCheckRollup) {
123
- const conclusion = String(check?.conclusion ?? '').toUpperCase();
124
167
  const status = String(check?.status ?? '').toUpperCase();
125
168
  const state = String(check?.state ?? '').toUpperCase();
126
169
  // In flight: a CheckRun not yet COMPLETED, or a legacy StatusContext still
@@ -131,14 +174,7 @@ export function deriveRequiredRunEvidence(statusCheckRollup) {
131
174
  } else if (state === 'PENDING' || state === 'EXPECTED') {
132
175
  requiredRunInFlight = true;
133
176
  }
134
- // Genuinely red: FAILURE / ERROR only. CANCELLED / TIMED_OUT / SKIPPED are
135
- // the superseded / sibling-invalidated noise a bare rollup miscounts.
136
- if (
137
- conclusion === 'FAILURE' ||
138
- conclusion === 'ERROR' ||
139
- state === 'FAILURE' ||
140
- state === 'ERROR'
141
- ) {
177
+ if (redConclusionOf(check)) {
142
178
  requiredRunFailed = true;
143
179
  }
144
180
  }
@@ -256,6 +292,47 @@ export function requiredCheckFailedBlocksMerge(prProbe) {
256
292
  */
257
293
  const MERGE_ADVISORY_STATE = 'UNSTABLE';
258
294
 
295
+ /**
296
+ * The fields a check projection can use to say, in its own words, WHY it went
297
+ * red. A legacy StatusContext carries `description`; a GitHub Actions CheckRun
298
+ * carries none of them in `gh pr view`'s fixed `statusCheckRollup` projection,
299
+ * so the merge wait enriches the run with the check-run API's
300
+ * `output.title` / `output.summary` before classifying (Story #5266). Both
301
+ * shapes are read here so the projection has ONE text extractor.
302
+ *
303
+ * @param {object} [check] A rollup entry, or an enriched check-run record.
304
+ * @returns {string|undefined} The joined text, or `undefined` when the record
305
+ * carries none — which is itself the signal that the run cannot be
306
+ * classified beyond "red".
307
+ */
308
+ export function readRunSummary(check) {
309
+ const parts = [];
310
+ for (const field of ['description', 'title', 'summary', 'text']) {
311
+ for (const value of [check?.[field], check?.output?.[field]]) {
312
+ if (typeof value === 'string' && value.trim()) parts.push(value.trim());
313
+ }
314
+ }
315
+ return parts.length > 0 ? parts.join(' — ') : undefined;
316
+ }
317
+
318
+ /**
319
+ * Pure: the workflow run id behind a check run's `detailsUrl`
320
+ * (`.../actions/runs/<runId>/job/<jobId>`), or `null` when the URL is absent
321
+ * or shaped otherwise (a legacy StatusContext's `targetUrl`, a third-party
322
+ * app's own page). A run with no id can never be re-run, which is why the
323
+ * rerun path treats `null` as "nothing to re-run" rather than an error.
324
+ *
325
+ * @param {string} [detailsUrl]
326
+ * @returns {number|null}
327
+ */
328
+ export function parseWorkflowRunId(detailsUrl) {
329
+ if (typeof detailsUrl !== 'string') return null;
330
+ const match = /\/actions\/runs\/(\d+)/.exec(detailsUrl);
331
+ if (!match) return null;
332
+ const id = Number.parseInt(match[1], 10);
333
+ return Number.isInteger(id) && id > 0 ? id : null;
334
+ }
335
+
259
336
  /**
260
337
  * Pure: project the HEAD-ANCHORED runs that genuinely concluded red, naming
261
338
  * each one (Story #5096).
@@ -273,28 +350,36 @@ const MERGE_ADVISORY_STATE = 'UNSTABLE';
273
350
  * conservative direction for a gate whose whole purpose is to stop a silent
274
351
  * landing.
275
352
  *
276
- * @param {Array<{name?: string, context?: string, status?: string, conclusion?: string, state?: string}>} statusCheckRollup
277
- * @returns {Array<{ name: string|null, conclusion: string }>}
353
+ * **Widened by Story #5266** from `{name, conclusion}` to carry what a red run
354
+ * is classified and acted on by: `summary` (the run's own account of why it
355
+ * failed — see {@link readRunSummary}), `runId` (the workflow run behind it,
356
+ * so a rerun can be requested), and `completedAt` (the observation stamp that
357
+ * tells a re-run's verdict apart from the stale pre-rerun one). Every added
358
+ * field is OMITTED when the projection carries no value for it, so a run the
359
+ * rollup describes as thinly as before still projects to exactly the old two
360
+ * keys — and a thin run classifies as a violation, i.e. the pre-#5266 verdict.
361
+ *
362
+ * @param {Array<{name?: string, context?: string, status?: string, conclusion?: string, state?: string, detailsUrl?: string, completedAt?: string, description?: string, output?: object}>} statusCheckRollup
363
+ * @returns {Array<{ name: string|null, conclusion: string, summary?: string, runId?: number, completedAt?: string }>}
278
364
  */
279
365
  export function deriveRedHeadRuns(statusCheckRollup) {
280
366
  if (!Array.isArray(statusCheckRollup)) return [];
281
367
  const red = [];
282
368
  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 });
369
+ const conclusion = redConclusionOf(check);
370
+ if (!conclusion) continue;
371
+ const summary = readRunSummary(check);
372
+ const runId = parseWorkflowRunId(check?.detailsUrl);
373
+ const completedAt = check?.completedAt;
374
+ red.push({
375
+ name: readRunName(check),
376
+ conclusion,
377
+ ...(summary ? { summary } : {}),
378
+ ...(runId ? { runId } : {}),
379
+ ...(typeof completedAt === 'string' && completedAt
380
+ ? { completedAt }
381
+ : {}),
382
+ });
298
383
  }
299
384
  return red;
300
385
  }
@@ -353,6 +438,130 @@ export function advisoryCheckFailedBlocksArm(prProbe, allowlist = []) {
353
438
  return selectBlockingRedRuns(prProbe?.redHeadRuns, allowlist).length > 0;
354
439
  }
355
440
 
441
+ /**
442
+ * The two advisory-gate block classes (Story #5266). Both BLOCK — the gate's
443
+ * verdict on whether to land is unchanged — but they authorise different acts,
444
+ * which is the whole reason they are two:
445
+ *
446
+ * - `advisory-gate-red` A red advisory run that REPORTED a
447
+ * violation. The change is implicated;
448
+ * landing over it is a deliberate override.
449
+ * - `advisory-gate-inconclusive` A red advisory run that never finished — a
450
+ * scan or navigation timeout that reported
451
+ * no violation at all. Nothing here says the
452
+ * change is bad, so the proportionate remedy
453
+ * is to re-run the job, not to grant the
454
+ * permanent global exemption
455
+ * `advisoryAllowlist` is.
456
+ */
457
+ export const ADVISORY_GATE_RED_CLASS = 'advisory-gate-red';
458
+ export const ADVISORY_GATE_INCONCLUSIVE_CLASS = 'advisory-gate-inconclusive';
459
+
460
+ /**
461
+ * Text signatures of a run that FAILED WITHOUT FINISHING. Deliberately narrow:
462
+ * the observed shape (Story #5266) is a `Navigation timeout of NNNN ms
463
+ * exceeded` line with an empty violation set, and anything this list does not
464
+ * recognise keeps the pre-#5266 `advisory-gate-red` verdict — the conservative
465
+ * direction, since misreading a real violation as a timeout would offer the
466
+ * operator a rerun for a finding that will come back every time.
467
+ */
468
+ const INCONCLUSIVE_MARKERS = Object.freeze([
469
+ /navigation timeout/i,
470
+ /timeout of \d+\s*m?s exceeded/i,
471
+ /\btimed out\b/i,
472
+ /\betimedout\b/i,
473
+ /\bdid not (?:finish|complete)\b/i,
474
+ /\b(?:scan|crawl|audit) (?:incomplete|aborted|interrupted)\b/i,
475
+ ]);
476
+
477
+ /** A counted finding — `0 violations` is explicitly NOT one. */
478
+ const VIOLATION_COUNT =
479
+ /\b(\d+)\s+(?:violation|error|issue|failure|problem|finding)s?\b/i;
480
+ /** An uncounted finding — enough on its own, because it names a verdict. */
481
+ const VIOLATION_WORD = /\bviolations?\b|\bfailed assertion/i;
482
+
483
+ /**
484
+ * Pure: does this red run's own text report a VIOLATION (as opposed to saying
485
+ * nothing, or saying it never got that far)?
486
+ *
487
+ * A counted phrase wins over the bare word so `0 violations found` — a scan
488
+ * that completed cleanly and then died — is not read as a finding.
489
+ *
490
+ * Takes the text, not the run: its one caller has already established the
491
+ * run says something, so a second empty-text guard here would be a branch no
492
+ * input can reach.
493
+ *
494
+ * @param {string} text A non-empty run summary.
495
+ * @returns {boolean}
496
+ */
497
+ function reportsViolations(text) {
498
+ const counted = VIOLATION_COUNT.exec(text);
499
+ if (counted) return Number.parseInt(counted[1], 10) > 0;
500
+ return VIOLATION_WORD.test(text);
501
+ }
502
+
503
+ /**
504
+ * Pure: classify ONE red advisory run as a genuine violation or an
505
+ * unfinished job (Story #5266).
506
+ *
507
+ * A run whose projection carries no text at all classifies as `violation`:
508
+ * absence of evidence is not evidence the job timed out, and `violation` is
509
+ * the verdict every red advisory run already got before this Story.
510
+ *
511
+ * @param {{ summary?: string }} [run]
512
+ * @returns {'violation'|'inconclusive'}
513
+ */
514
+ function classifyAdvisoryRedRun(run) {
515
+ const text = typeof run?.summary === 'string' ? run.summary : '';
516
+ if (!text || reportsViolations(text)) return 'violation';
517
+ return INCONCLUSIVE_MARKERS.some((marker) => marker.test(text))
518
+ ? 'inconclusive'
519
+ : 'violation';
520
+ }
521
+
522
+ /**
523
+ * Pure: the block class for a whole set of blocking runs.
524
+ *
525
+ * `advisory-gate-inconclusive` requires EVERY blocking run to be inconclusive.
526
+ * One genuine violation beside a timeout is still a genuine violation, and the
527
+ * operator must not be offered a rerun as the remedy for it.
528
+ *
529
+ * Module-private: {@link resolveAdvisoryGateVerdict} is the one door, so a
530
+ * caller cannot take the class without the reason that matches it — and, being
531
+ * the one door, it is also what normalises `blockingRuns` to an array, so
532
+ * neither this nor {@link formatAdvisoryGateReason} re-guards the shape.
533
+ *
534
+ * @param {Array<{ summary?: string }>} runs
535
+ * @returns {string} one of the two advisory classes above
536
+ */
537
+ function deriveAdvisoryGateClass(runs) {
538
+ if (runs.length === 0) return ADVISORY_GATE_RED_CLASS;
539
+ return runs.every((run) => classifyAdvisoryRedRun(run) === 'inconclusive')
540
+ ? ADVISORY_GATE_INCONCLUSIVE_CLASS
541
+ : ADVISORY_GATE_RED_CLASS;
542
+ }
543
+
544
+ /**
545
+ * Pure: the advisory gate's whole verdict — class AND the reason text that
546
+ * matches it (Story #5266). One function so a caller can never pair an
547
+ * inconclusive class with the violation wording.
548
+ *
549
+ * @param {{ blockingRuns?: Array<object>, rerunAllowance?: number }} [args]
550
+ * @returns {{ blockClass: string, blockingRuns: Array<object>, reason: string }}
551
+ */
552
+ export function resolveAdvisoryGateVerdict({
553
+ blockingRuns,
554
+ rerunAllowance = 0,
555
+ } = {}) {
556
+ const runs = Array.isArray(blockingRuns) ? blockingRuns : [];
557
+ const blockClass = deriveAdvisoryGateClass(runs);
558
+ return {
559
+ blockClass,
560
+ blockingRuns: runs,
561
+ reason: formatAdvisoryGateReason(runs, { blockClass, rerunAllowance }),
562
+ };
563
+ }
564
+
356
565
  /**
357
566
  * Pure: the merge wait's advisory-gate decision, the sibling of
358
567
  * {@link decideMergeWaitFailFast} (Story #5096).
@@ -366,13 +575,19 @@ export function advisoryCheckFailedBlocksArm(prProbe, allowlist = []) {
366
575
  * Returns `null` when the wait should keep polling — the knob is off, the PR
367
576
  * is not in the advisory-red state, or every red run is allowlisted.
368
577
  *
578
+ * The verdict it returns is provisional on the text the ROLLUP carried
579
+ * (Story #5266): the caller may enrich the blocking runs with the check-run
580
+ * API's output and re-resolve via {@link resolveAdvisoryGateVerdict} before
581
+ * recording the block.
582
+ *
369
583
  * @param {object} args
370
- * @returns {{ blockingRuns: Array<object>, reason: string } | null}
584
+ * @returns {{ blockingRuns: Array<object>, reason: string, blockClass: string } | null}
371
585
  */
372
586
  export function decideAdvisoryGateBlock({
373
587
  probe,
374
588
  blockOnAdvisoryFailure,
375
589
  advisoryAllowlist,
590
+ rerunAllowance = 0,
376
591
  }) {
377
592
  if (!blockOnAdvisoryFailure) return null;
378
593
  if (!advisoryCheckFailedBlocksArm(probe, advisoryAllowlist)) return null;
@@ -380,30 +595,59 @@ export function decideAdvisoryGateBlock({
380
595
  probe?.redHeadRuns,
381
596
  advisoryAllowlist,
382
597
  );
383
- return { blockingRuns, reason: formatAdvisoryGateReason(blockingRuns) };
598
+ return resolveAdvisoryGateVerdict({ blockingRuns, rerunAllowance });
384
599
  }
385
600
 
386
601
  /**
387
602
  * 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.
603
+ * block carry for an advisory-gate verdict, naming each offending job and its
604
+ * conclusion.
605
+ *
606
+ * Story #5266 splits the wording by class: an unfinished job is reported as
607
+ * one, because telling an operator a timed-out scan "concluded red" invites
608
+ * them to grant a permanent `advisoryAllowlist` exemption for a transient
609
+ * failure. Both wordings name the same three remedies — rerun, hand-merge,
610
+ * allowlist — in the order proportionate to the class.
390
611
  *
391
- * @param {Array<{ name: string|null, conclusion: string }>} blockingRuns
612
+ * Module-private for the same reason {@link deriveAdvisoryGateClass} is: the
613
+ * class and the wording must travel together.
614
+ *
615
+ * @param {Array<{ name: string|null, conclusion: string }>} runs
616
+ * @param {{ blockClass: string, rerunAllowance: number }} options
392
617
  * @returns {string}
393
618
  */
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(', ');
619
+ function formatAdvisoryGateReason(runs, { blockClass, rerunAllowance }) {
620
+ const named =
621
+ runs
622
+ .map(
623
+ (run) =>
624
+ `${run?.name ?? '(unnamed run)'} → ${run?.conclusion ?? 'FAILURE'}`,
625
+ )
626
+ .join(', ') || '(none named)';
627
+ const spent =
628
+ rerunAllowance > 0
629
+ ? `The rerun allowance (${rerunAllowance}) is already spent on this head. `
630
+ : '';
631
+ if (blockClass === ADVISORY_GATE_INCONCLUSIVE_CLASS) {
632
+ return (
633
+ 'A non-required (advisory) check FAILED WITHOUT FINISHING on the PR ' +
634
+ 'head — it reported no violation, so nothing here says the change is ' +
635
+ 'bad — and GitHub reports the PR mergeable anyway ' +
636
+ '(mergeStateStatus=UNSTABLE), so native auto-merge would land it over ' +
637
+ `the failure. Unfinished advisory job(s): ${named}. ` +
638
+ `${spent}Re-run the job (--rerun-advisory <n>, or ` +
639
+ 'delivery.ci.rerunAdvisory), merge by hand to land over it ' +
640
+ 'deliberately, or exempt the job via delivery.ci.advisoryAllowlist.'
641
+ );
642
+ }
401
643
  return (
402
644
  'A non-required (advisory) check concluded red on the PR head, and GitHub ' +
403
645
  'reports the PR mergeable anyway (mergeStateStatus=UNSTABLE) — native ' +
404
646
  '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.'
647
+ `${named}. ${spent}Merge by hand to land over it ` +
648
+ 'deliberately, re-run the job (--rerun-advisory <n>, or ' +
649
+ 'delivery.ci.rerunAdvisory), or exempt the job via ' +
650
+ 'delivery.ci.advisoryAllowlist.'
407
651
  );
408
652
  }
409
653
 
@@ -145,6 +145,7 @@ export async function adoptContainerEpic({
145
145
  provider,
146
146
  epicNumber: target.id,
147
147
  childIds,
148
+ created: all,
148
149
  });
149
150
 
150
151
  Logger.info(
@@ -164,6 +165,24 @@ export async function adoptContainerEpic({
164
165
  /**
165
166
  * Write the appended checklist back to the Epic body.
166
167
  *
168
+ * **The body is re-read `fresh` immediately before the append.** `target.body`
169
+ * was captured by `resolveAdoptionTarget` before the first Story was created,
170
+ * which on a cohort of any size is many seconds and several writes ago. An
171
+ * append computed against that snapshot and PATCHed wholesale silently drops
172
+ * every checklist row another writer added in between — a concurrent persist
173
+ * run adopting the same Epic, or an operator ticking a child off by hand. The
174
+ * body is a full-document write, so a stale base is not a merge conflict; it
175
+ * is a silent revert.
176
+ *
177
+ * This **narrows** the read-then-PATCH window; it does not close it. Nothing
178
+ * here is atomic, and GitHub's issue API offers no compare-and-swap, so a
179
+ * write landing between this read and this PATCH is still lost. Narrowing it
180
+ * from "the whole create phase" to "one round-trip" is the available fix; a
181
+ * real one needs an API that does not exist.
182
+ *
183
+ * The PATCH is skipped when the append changes nothing, so re-running an
184
+ * adoption that already landed writes nothing at all.
185
+ *
167
186
  * Non-fatal: the Stories are already live, and the native sub-issue edges
168
187
  * written next are the other half of the linkage. Losing the checklist costs
169
188
  * the body-only fallback path, not the grouping.
@@ -179,8 +198,9 @@ async function appendChecklist({ provider, target, childIds }) {
179
198
  );
180
199
  return;
181
200
  }
182
- const next = appendEpicChildIds(target.body, childIds);
183
- if (next === target.body) return;
201
+ const base = await readFreshEpicBody({ provider, target });
202
+ const next = appendEpicChildIds(base, childIds);
203
+ if (next === base) return;
184
204
  try {
185
205
  await provider.updateTicket(target.id, { body: next });
186
206
  } catch (err) {
@@ -190,3 +210,30 @@ async function appendChecklist({ provider, target, childIds }) {
190
210
  );
191
211
  }
192
212
  }
213
+
214
+ /**
215
+ * Re-read the Epic's body, bypassing any provider cache.
216
+ *
217
+ * `{ fresh: true }` is the whole point: the adoption path already read this
218
+ * issue once, so a cached read would hand back the very snapshot this function
219
+ * exists to replace. Falls back to the snapshot when the re-read fails or the
220
+ * provider has no `getTicket` — a stale base still appends the run's own
221
+ * children, which beats not linking them.
222
+ *
223
+ * @param {{ provider: object, target: { id: number, body: string } }} opts
224
+ * @returns {Promise<string>}
225
+ */
226
+ async function readFreshEpicBody({ provider, target }) {
227
+ if (typeof provider?.getTicket !== 'function') return target.body;
228
+ try {
229
+ const fresh = await provider.getTicket(target.id, { fresh: true });
230
+ if (typeof fresh?.body === 'string') return fresh.body;
231
+ } catch (err) {
232
+ Logger.warn(
233
+ `[plan-persist] could not re-read Epic #${target.id} before appending its ` +
234
+ `checklist (${err?.message ?? err}); appending to the body read earlier. ` +
235
+ 'A child linked by another writer since then may be dropped.',
236
+ );
237
+ }
238
+ return target.body;
239
+ }
@@ -123,10 +123,10 @@ async function ensureEpicLabel({ provider }) {
123
123
  * @returns {Promise<{ id: number, url?: string }|null>}
124
124
  */
125
125
  async function findExistingEpic({ provider, fingerprint }) {
126
- if (typeof provider?.listIssuesByLabel !== 'function') return null;
126
+ if (typeof provider?.listTicketsByLabel !== 'function') return null;
127
127
  try {
128
128
  const marker = epicFingerprintMarker(fingerprint);
129
- const found = await provider.listIssuesByLabel({
129
+ const found = await provider.listTicketsByLabel({
130
130
  state: 'open',
131
131
  labels: TYPE_LABELS.EPIC,
132
132
  });
@@ -134,9 +134,12 @@ async function findExistingEpic({ provider, fingerprint }) {
134
134
  String(issue?.body ?? '').includes(marker),
135
135
  );
136
136
  if (!hit) return null;
137
- const id = Number(hit.number ?? hit.id);
137
+ // The declared ticket shape: `id` is the issue number. The `number`-then-
138
+ // `id` fallback this replaced would have adopted the resumed container by
139
+ // database id — a number that exists, resolves to nothing, fails no guard.
140
+ const id = Number(hit.id);
138
141
  if (!Number.isInteger(id) || id <= 0) return null;
139
- return { id, url: hit.html_url ?? hit.url ?? undefined };
142
+ return { id, url: hit.url ?? undefined };
140
143
  } catch (err) {
141
144
  Logger.warn(
142
145
  `[plan-persist] Epic resume lookup failed (${err.message}); creating a new container.`,
@@ -145,6 +148,26 @@ async function findExistingEpic({ provider, fingerprint }) {
145
148
  }
146
149
  }
147
150
 
151
+ /**
152
+ * Index a cohort's already-known database ids by issue number.
153
+ *
154
+ * A resumed run's adopted Stories carry no `internalId` — they were found by
155
+ * listing, not created — so they are simply absent from the map and fall
156
+ * through to the lookup. Absence means "not known here", never "has none".
157
+ *
158
+ * @param {Array<{ id?: number, internalId?: number }>|undefined} created
159
+ * @returns {Map<number, number>}
160
+ */
161
+ function internalIdsFrom(created) {
162
+ const map = new Map();
163
+ for (const story of Array.isArray(created) ? created : []) {
164
+ if (Number.isInteger(story?.id) && typeof story?.internalId === 'number') {
165
+ map.set(story.id, story.internalId);
166
+ }
167
+ }
168
+ return map;
169
+ }
170
+
148
171
  /**
149
172
  * Link the created Stories under the Epic as native sub-issue edges.
150
173
  *
@@ -156,10 +179,18 @@ async function findExistingEpic({ provider, fingerprint }) {
156
179
  * children exactly the way creation does — one mirroring rule, not two that
157
180
  * drift.
158
181
  *
159
- * @param {{ provider: object, epicNumber: number, childIds: number[] }} opts
182
+ * `created` is optional and carries the cohort's `createIssue` responses, so
183
+ * the linker can skip the id lookup for every child this run made itself.
184
+ *
185
+ * @param {{ provider: object, epicNumber: number, childIds: number[], created?: Array<{ id: number, internalId?: number }> }} opts
160
186
  * @returns {Promise<{ added: number, skipped: number, failed: number }|null>}
161
187
  */
162
- export async function mirrorSubIssueEdges({ provider, epicNumber, childIds }) {
188
+ export async function mirrorSubIssueEdges({
189
+ provider,
190
+ epicNumber,
191
+ childIds,
192
+ created = [],
193
+ }) {
163
194
  if (
164
195
  typeof provider?.getDependencyWriteContext !== 'function' ||
165
196
  typeof provider?.getTicket !== 'function'
@@ -176,6 +207,7 @@ export async function mirrorSubIssueEdges({ provider, epicNumber, childIds }) {
176
207
  const summary = await linkStoriesToEpic({
177
208
  epicNumber,
178
209
  childIssueNumbers: childIds,
210
+ knownInternalIds: internalIdsFrom(created),
179
211
  getTicket: (issueNumber) => provider.getTicket(issueNumber),
180
212
  owner,
181
213
  repo,
@@ -285,6 +317,7 @@ export async function createContainerEpic({
285
317
  provider,
286
318
  epicNumber: existing.id,
287
319
  childIds,
320
+ created,
288
321
  });
289
322
  return {
290
323
  id: existing.id,
@@ -303,11 +336,14 @@ export async function createContainerEpic({
303
336
  labels: [TYPE_LABELS.EPIC],
304
337
  });
305
338
 
306
- const epicNumber = result.number ?? result.id;
339
+ // `createIssue` declares both `id` and `number` and sets them to the same
340
+ // issue number; reading one of them is the whole contract.
341
+ const epicNumber = result.id;
307
342
  const edges = await mirrorSubIssueEdges({
308
343
  provider,
309
344
  epicNumber,
310
345
  childIds,
346
+ created,
311
347
  });
312
348
 
313
349
  Logger.info(