mandrel 2.54.0 → 2.56.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 (134) 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 +8 -2
  5. package/.agents/docs/configuration.md +5 -0
  6. package/.agents/rules/ci-remediation.md +39 -21
  7. package/.agents/schemas/agentrc.schema.json +34 -1
  8. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  9. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  10. package/.agents/scripts/audit-to-stories.js +374 -76
  11. package/.agents/scripts/check-audit-attribution.js +119 -62
  12. package/.agents/scripts/check-test-portability.js +512 -0
  13. package/.agents/scripts/coverage-capture.js +17 -10
  14. package/.agents/scripts/evidence-gate.js +31 -4
  15. package/.agents/scripts/file-ci-gap.js +306 -0
  16. package/.agents/scripts/generate-workflows-doc.js +65 -14
  17. package/.agents/scripts/git-cleanup.js +4 -0
  18. package/.agents/scripts/lib/ITicketingProvider.js +78 -0
  19. package/.agents/scripts/lib/audit-advisories.js +195 -0
  20. package/.agents/scripts/lib/audit-attribution.js +22 -0
  21. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +25 -1
  22. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +80 -29
  23. package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
  24. package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
  25. package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
  26. package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
  27. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +61 -115
  28. package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
  29. package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
  30. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
  31. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
  32. package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
  33. package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
  34. package/.agents/scripts/lib/cli-args.js +26 -0
  35. package/.agents/scripts/lib/close-validation/gates.js +113 -7
  36. package/.agents/scripts/lib/close-validation/process.js +7 -3
  37. package/.agents/scripts/lib/close-validation/runner.js +62 -11
  38. package/.agents/scripts/lib/config/ci.js +28 -9
  39. package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
  40. package/.agents/scripts/lib/config-settings-schema.js +52 -1
  41. package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
  42. package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
  43. package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
  44. package/.agents/scripts/lib/coverage-capture.js +77 -3
  45. package/.agents/scripts/lib/feedback-loop/graduator-core.js +53 -13
  46. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +71 -25
  47. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +18 -25
  48. package/.agents/scripts/lib/{audit-to-stories/ledger.js → findings/audit-ledger.js} +131 -24
  49. package/.agents/scripts/lib/findings/route-finding.js +42 -2
  50. package/.agents/scripts/lib/full-suite-lock.js +232 -6
  51. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  52. package/.agents/scripts/lib/git/sync-from-base.js +130 -13
  53. package/.agents/scripts/lib/github/framework-repo.js +148 -2
  54. package/.agents/scripts/lib/label-constants.js +6 -1
  55. package/.agents/scripts/lib/observability/source-classifier.js +2 -0
  56. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
  57. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
  58. package/.agents/scripts/lib/orchestration/ci-gap-intake.js +605 -0
  59. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
  60. package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
  61. package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
  62. package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
  63. package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
  64. package/.agents/scripts/lib/orchestration/epic-rollup.js +233 -84
  65. package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
  66. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
  67. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
  68. package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
  69. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
  70. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
  71. package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
  72. package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
  73. package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
  74. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
  75. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
  76. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +39 -3
  77. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
  78. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
  79. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -0
  80. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
  81. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
  82. package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
  83. package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
  84. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
  85. package/.agents/scripts/lib/orchestration/run-epilogue.js +63 -42
  86. package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
  87. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
  88. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
  89. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
  90. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
  91. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
  92. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
  93. package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
  94. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
  95. package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
  96. package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
  97. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +30 -0
  98. package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
  99. package/.agents/scripts/lib/pinned-override-notes.js +41 -53
  100. package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
  101. package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
  102. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
  103. package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
  104. package/.agents/scripts/lib/test-temp.js +167 -30
  105. package/.agents/scripts/lib/validation-evidence.js +37 -0
  106. package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
  107. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  108. package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
  109. package/.agents/scripts/merge-baseline.js +175 -21
  110. package/.agents/scripts/pr-watch-with-update.js +3 -2
  111. package/.agents/scripts/providers/github/errors.js +22 -1
  112. package/.agents/scripts/providers/github/issues.js +106 -1
  113. package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
  114. package/.agents/scripts/providers/github.js +6 -0
  115. package/.agents/scripts/resolve-stories.js +44 -34
  116. package/.agents/scripts/single-story-close.js +5 -0
  117. package/.agents/scripts/stories-wave-tick.js +37 -13
  118. package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
  119. package/.agents/workflows/audit-accessibility.md +16 -31
  120. package/.agents/workflows/audit-mobile.md +20 -37
  121. package/.agents/workflows/audit-to-stories.md +63 -27
  122. package/.agents/workflows/git-cleanup.md +17 -3
  123. package/.agents/workflows/helpers/audit-lens-core.md +45 -0
  124. package/.agents/workflows/helpers/deliver-digest.md +7 -6
  125. package/.agents/workflows/helpers/deliver-reference.md +35 -14
  126. package/.agents/workflows/helpers/deliver-story-reference.md +26 -8
  127. package/.agents/workflows/helpers/deliver-story.md +15 -12
  128. package/.agents/workflows/helpers/plan-reference.md +30 -0
  129. package/.agents/workflows/mandrel-plan.md +10 -13
  130. package/.agents/workflows/memory-consolidate.md +14 -9
  131. package/docs/CHANGELOG.md +37 -0
  132. package/lib/cli/registry.js +64 -21
  133. package/lib/cli/sync.js +27 -2
  134. package/package.json +7 -4
@@ -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(
@@ -38,7 +38,7 @@
38
38
  */
39
39
 
40
40
  import { rm } from 'node:fs/promises';
41
- import { getLimits, PROJECT_ROOT } from '../../config-resolver.js';
41
+ import { getLimits, getPaths, PROJECT_ROOT } from '../../config-resolver.js';
42
42
  import { gitSpawn } from '../../git-utils.js';
43
43
  import { Logger } from '../../Logger.js';
44
44
  import { sweepTempRetention } from '../../temp-retention.js';
@@ -68,6 +68,7 @@ import {
68
68
  renderHardConflictError,
69
69
  } from '../ticket-validator-conflicts.js';
70
70
  import { upsertStructuredComment } from '../ticketing.js';
71
+ import { recordAuditFilings, withAuditLabels } from './audit-provenance.js';
71
72
  import {
72
73
  resolveContainerEpic,
73
74
  resolveCrossPlanLinks,
@@ -88,6 +89,7 @@ import {
88
89
  PLAN_SUMMARY_COMMENT_TYPE,
89
90
  } from './summary.js';
90
91
  import { closeSupersededTickets } from './supersede-ops.js';
92
+ import { predictWaveSerialisation } from './wave-serialisation.js';
91
93
 
92
94
  /** Checkpoint schema version written on each Story's story-plan-state. */
93
95
  const PLAN_CHECKPOINT_SCHEMA_VERSION_V2 = 2;
@@ -212,6 +214,7 @@ async function runSupersedePhase(args) {
212
214
  reason: `phase-error: ${err.message}`,
213
215
  closed: [],
214
216
  planned: [],
217
+ epicRollup: { closed: [], pending: [] },
215
218
  skipped: [],
216
219
  failed: (args.sourceTicketIds ?? []).map((ticket) => ({
217
220
  ticket,
@@ -778,7 +781,8 @@ export async function runPlanPersist({
778
781
  });
779
782
 
780
783
  // Split policy + inline Spec fold (over-budget Specs fail closed — no docs/).
781
- const { stories } = assemblePlanStories(rawStories, {
784
+ const seedContent = planContextEnvelope?.seed?.content ?? '';
785
+ const { stories: assembled } = assemblePlanStories(rawStories, {
782
786
  sharedSpec: techSpecContent,
783
787
  planAcceptance: planAcceptance ?? undefined,
784
788
  sourceTicketIds,
@@ -789,9 +793,16 @@ export async function runPlanPersist({
789
793
  // is the **fallback** — it is carried onto every Story that did not
790
794
  // attribute its own `provenance`, which keeps an un-attributed plan exactly
791
795
  // as recall-safe as it was. Empty for a `--tickets` run, a no-op there.
792
- provenanceSource: planContextEnvelope?.seed?.content ?? '',
796
+ provenanceSource: seedContent,
793
797
  });
794
798
 
799
+ // Stamp the `audit::*` labels the dedup corpus is listed by. Without them a
800
+ // Story this path files is absent from the pool an indexed sweep matches
801
+ // against, and an indexed run answers exact lookups from that pool without
802
+ // ever reaching the provider — so the provenance footers alone leave it
803
+ // invisible (Story #5307). A non-audit seed carries none: a no-op there.
804
+ const stories = withAuditLabels(assembled, seedContent);
805
+
795
806
  // Story #5045: the cross-Story conflict passes re-run over the assembled,
796
807
  // footer-stamped bodies — the artifact persist actually writes — before any
797
808
  // GitHub call, so a policy upgrade still refuses the plan pre-creation.
@@ -822,6 +833,10 @@ export async function runPlanPersist({
822
833
  opts: { dryRun, routeLabel: isLiteRoute ? LITE_ROUTE_LABEL : null },
823
834
  });
824
835
 
836
+ // What this run filed, recorded where the next audit sweep reads it
837
+ // (Story #5307). A dry run created nothing, and the call knows it.
838
+ recordAuditFilings({ stories, created, tickets: rawStories, dryRun });
839
+
825
840
  const primary = created[0];
826
841
  const waveTable = buildWaveTable(
827
842
  stories.map((s) => ({
@@ -831,6 +846,17 @@ export async function runPlanPersist({
831
846
  })),
832
847
  );
833
848
 
849
+ // Story #5265: the table says which Stories share an order; the dispatcher
850
+ // decides which of those actually run together, and it decides on the
851
+ // evidence-widened footprint. Run its own predicate over the assembled
852
+ // bodies — the exact artifact the tick will read back off GitHub — so the
853
+ // comment names the serialisation instead of promising parallelism the
854
+ // next tick refuses. `tempRoot` is threaded for the same reason the tick
855
+ // threads it: the scrape must ignore this project's scratch root.
856
+ const waveCollisions = predictWaveSerialisation(waveTable, stories, {
857
+ tempRoot: getPaths(config).tempRoot,
858
+ });
859
+
834
860
  // Story #4541: `readPlanMetrics` is declared `(epicId, config)` but was
835
861
  // called with `config` first, so the ledger path resolver received the
836
862
  // config object as an `epicId` and threw its guard on every single run —
@@ -860,6 +886,7 @@ export async function runPlanPersist({
860
886
  // collisions the conflict passes found belong on the same surface, or the
861
887
  // promise is the only half anyone reads.
862
888
  conflictFindings: assembledConflicts,
889
+ waveCollisions,
863
890
  });
864
891
 
865
892
  if (!dryRun) {
@@ -890,6 +917,10 @@ export async function runPlanPersist({
890
917
  stories,
891
918
  created,
892
919
  sourceTicketIds,
920
+ // Story #5280 — closing a source ticket is a child state change, so the
921
+ // phase re-derives the container above it. It needs the config the rollup
922
+ // reads its board and operator handle from.
923
+ config,
893
924
  dryRun,
894
925
  closeSuperseded,
895
926
  });
@@ -915,6 +946,11 @@ export async function runPlanPersist({
915
946
  reachability,
916
947
  freshness,
917
948
  waveTable,
949
+ // Story #5265 AC-2/AC-4: both halves of what persist concluded but used
950
+ // to keep to itself — the `refactors-existing` declarations it rewrote,
951
+ // and the same-order pairs the dispatcher will serialize.
952
+ assumptionNormalizations: validated.normalizations ?? [],
953
+ waveCollisions,
918
954
  supersede,
919
955
  epic: containerEpic,
920
956
  };
@@ -1098,6 +1098,11 @@ export async function createStoryIssues({ provider, stories, opts = {} }) {
1098
1098
  id,
1099
1099
  title: story.title,
1100
1100
  url: result.url,
1101
+ // Kept, not discarded (Story #5280). `createIssue` hands back the
1102
+ // database id, which is the only identifier the native sub-issue write
1103
+ // accepts — and dropping it here is what made the Epic linker re-read
1104
+ // every Story this run had just created to recover it.
1105
+ internalId: result.internalId,
1101
1106
  // True when the provider's retry probe adopted an issue a lost-response
1102
1107
  // first attempt had already filed — pre-existing either way.
1103
1108
  adopted: result.adopted === true,
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { computeStoryWaves } from '../dependency-analyzer.js';
17
+ import { renderPredictedSerialisationLines } from './wave-serialisation.js';
17
18
 
18
19
  /**
19
20
  * Structured-comment type for the persist summary.
@@ -129,6 +130,7 @@ export function buildPlanSummaryCommentBody({
129
130
  planMetricsLine = null,
130
131
  stories = null,
131
132
  conflictFindings = null,
133
+ waveCollisions = null,
132
134
  // legacy unused knobs kept so older test call sites don't crash mid-migration
133
135
  single = null,
134
136
  amend = null,
@@ -180,6 +182,7 @@ export function buildPlanSummaryCommentBody({
180
182
  '#### Delivery order (`depends_on`)',
181
183
  '',
182
184
  ...renderWaveTableLines(waveTable),
185
+ ...renderPredictedSerialisationLines(waveCollisions),
183
186
  ...renderSharedEditorLines(conflictFindings),
184
187
  '',
185
188
  `_Deliver with \`${deliverCommand}\` — \`/mandrel-deliver\` resolves the dependency graph from live state, so edges may point at Stories from earlier plan runs._`,
@@ -50,6 +50,7 @@ import {
50
50
  concurrentMap,
51
51
  FANOUT_CONCURRENCY,
52
52
  } from '../../util/concurrent-map.js';
53
+ import { rollUpEpicForStory } from '../epic-rollup.js';
53
54
  import { upsertStructuredComment } from '../ticketing.js';
54
55
 
55
56
  /** Structured-comment type marking a source issue as superseded. */
@@ -444,6 +445,9 @@ async function closeOneSupersededTicket({
444
445
  * slug is the only identifier that means anything before the writes land.
445
446
  * @property {Array<{ ticket: number, reason: string }>} skipped
446
447
  * @property {Array<{ ticket: number, reason: string }>} failed
448
+ * @property {{ closed: number[], pending: number[] }} epicRollup Container
449
+ * Epics this phase's closes resolved. Empty on a dry run and on a phase that
450
+ * closed nothing.
447
451
  */
448
452
 
449
453
  function emptyReport(overrides) {
@@ -455,10 +459,53 @@ function emptyReport(overrides) {
455
459
  planned: [],
456
460
  skipped: [],
457
461
  failed: [],
462
+ epicRollup: { closed: [], pending: [] },
458
463
  ...overrides,
459
464
  };
460
465
  }
461
466
 
467
+ /**
468
+ * Re-derive the container Epic above every ticket this phase just closed.
469
+ *
470
+ * Closing a source ticket is a child state change like any other, and it was
471
+ * the one edge with no rollup behind it. Superseding a cohort therefore left
472
+ * its container open indefinitely: every child was closed, so no delivery
473
+ * would ever run and no land tail would ever fire the derivation. The Epic sat
474
+ * open above finished work until someone noticed.
475
+ *
476
+ * Runs **after** the closes land, never alongside them: the rollup reads each
477
+ * child's state back, so racing it against the writes it is meant to observe
478
+ * would derive from a tree half of which has not been written yet.
479
+ *
480
+ * Sequential, sharing one `skipEpicIds` set, because siblings share a
481
+ * container — without it the second ticket re-derives, and re-closes, the Epic
482
+ * the first already closed. `rollUpEpicForStory` never throws, so no guard is
483
+ * needed here beyond the phase-level one the caller already holds.
484
+ *
485
+ * @param {{ closedIds: number[], provider: object, config?: object }} opts
486
+ * @returns {Promise<{ closed: number[], pending: number[] }>}
487
+ */
488
+ async function rollUpContainersFor({ closedIds, provider, config }) {
489
+ const seen = new Set();
490
+ const closed = new Set();
491
+ const pending = new Set();
492
+ for (const storyId of closedIds) {
493
+ const outcome = await rollUpEpicForStory({
494
+ storyId,
495
+ provider,
496
+ config,
497
+ skipEpicIds: seen,
498
+ });
499
+ for (const epic of outcome.epics) seen.add(epic.epicId);
500
+ for (const epicId of outcome.closed) closed.add(epicId);
501
+ for (const epicId of outcome.pending) pending.add(epicId);
502
+ }
503
+ // A container reported pending by one ticket's rollup and closed by a later
504
+ // one is closed: the last answer saw the whole cohort.
505
+ for (const epicId of closed) pending.delete(epicId);
506
+ return { closed: [...closed], pending: [...pending] };
507
+ }
508
+
462
509
  /**
463
510
  * Comment on and close every superseded source ticket.
464
511
  *
@@ -471,6 +518,7 @@ function emptyReport(overrides) {
471
518
  * @param {Array<{ slug: string, supersedes: Array<{ id: number, note: string|null }> }>} args.stories
472
519
  * @param {Array<{ slug: string, id: number, title: string }>} args.created
473
520
  * @param {number[]} args.sourceTicketIds
521
+ * @param {object} [args.config] Resolved `.agentrc.json`, for the Epic rollup.
474
522
  * @param {boolean} [args.dryRun=false]
475
523
  * @param {boolean} [args.closeSuperseded=true]
476
524
  * @returns {Promise<SupersedeReport>}
@@ -480,6 +528,7 @@ export async function closeSupersededTickets({
480
528
  stories,
481
529
  created,
482
530
  sourceTicketIds,
531
+ config,
483
532
  dryRun = false,
484
533
  closeSuperseded = true,
485
534
  }) {
@@ -532,6 +581,14 @@ export async function closeSupersededTickets({
532
581
  recordSupersedeOutcome(report, units[index], result);
533
582
  });
534
583
 
584
+ if (report.closed.length > 0) {
585
+ report.epicRollup = await rollUpContainersFor({
586
+ closedIds: report.closed,
587
+ provider,
588
+ config,
589
+ });
590
+ }
591
+
535
592
  logSupersedeReport(report);
536
593
  return report;
537
594
  }
@@ -613,4 +670,10 @@ function logSupersedeReport(report) {
613
670
  'close it by hand.',
614
671
  );
615
672
  }
673
+ if (report.epicRollup.closed.length > 0) {
674
+ Logger.info(
675
+ `[plan-persist] container Epic(s) closed by the supersede rollup: ` +
676
+ `${report.epicRollup.closed.map((id) => `#${id}`).join(', ')}.`,
677
+ );
678
+ }
616
679
  }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * wave-serialisation.js — what the dispatcher will actually do with the wave
3
+ * table plan-persist prints (Story #5265).
4
+ *
5
+ * Kept out of `summary.js` because it answers a different question. The
6
+ * summary module renders receipts persist already computed; this one runs the
7
+ * *runtime's* collision predicate over the assembled Story bodies to work out
8
+ * which of the table's promises the next tick will refuse to keep.
9
+ *
10
+ * @module lib/orchestration/plan-persist/wave-serialisation
11
+ */
12
+
13
+ import {
14
+ detectCollision,
15
+ OVERLAP_SOURCES,
16
+ renderScrapeAttribution,
17
+ } from '../../wave-runner/footprint.js';
18
+
19
+ /**
20
+ * Predict which same-wave pairs the dispatch guard will actually refuse to
21
+ * co-dispatch (Story #5265).
22
+ *
23
+ * The wave table answers a `depends_on` question, and the runtime answers a
24
+ * different one: `stories-wave-tick.js` withholds on {@link detectCollision},
25
+ * whose footprint is the declared `changes[]` **plus** every path scraped out
26
+ * of the Story's title, spec and serialized body — and that body carries
27
+ * `## Verify`, so two Stories that merely run the same gate script share a
28
+ * path neither will edit. The table therefore promised parallelism the very
29
+ * next tick refused, with nothing anywhere reconciling the two.
30
+ *
31
+ * This runs the runtime's own exported predicate — not a reimplementation of
32
+ * it — pairwise within each wave, so the prediction cannot drift from the
33
+ * behaviour it predicts.
34
+ *
35
+ * @param {ReturnType<typeof buildWaveTable>} waveTable
36
+ * @param {Array<{ slug: string, title?: string, body?: string, spec?: string, changes?: Array }>} stories
37
+ * @param {{ tempRoot?: string }} [options]
38
+ * @returns {Array<{ wave: number, slugs: [string, string], paths: string[], source: string, attribution: object[] }>}
39
+ */
40
+ export function predictWaveSerialisation(waveTable, stories, options = {}) {
41
+ const bySlug = new Map(
42
+ (Array.isArray(stories) ? stories : []).map((s) => [s.slug, s]),
43
+ );
44
+ const out = [];
45
+ for (const { wave, stories: members } of Array.isArray(waveTable)
46
+ ? waveTable
47
+ : []) {
48
+ for (let i = 0; i < members.length; i += 1) {
49
+ for (let j = i + 1; j < members.length; j += 1) {
50
+ const a = bySlug.get(members[i].slug);
51
+ const b = bySlug.get(members[j].slug);
52
+ if (!a || !b) continue;
53
+ const collision = detectCollision(a, b, options);
54
+ if (collision) {
55
+ out.push({
56
+ wave,
57
+ slugs: [members[i].slug, members[j].slug],
58
+ ...collision,
59
+ });
60
+ }
61
+ }
62
+ }
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /**
68
+ * Render the predicted serialisation beside the wave table (Story #5265).
69
+ *
70
+ * Same shape as {@link renderSharedEditorLines} on purpose: both are caveats
71
+ * against the same promise, and an operator should read them the same way.
72
+ * The difference is what they know — the shared-editor pass names paths two
73
+ * Stories both *write*, this one names every pair the dispatcher will refuse
74
+ * to run together whatever the reason, including the pairs whose only shared
75
+ * path was scraped out of a `## Verify` line.
76
+ *
77
+ * @param {ReturnType<typeof predictWaveSerialisation>} collisions
78
+ * @returns {string[]}
79
+ */
80
+ export function renderPredictedSerialisationLines(collisions) {
81
+ const list = Array.isArray(collisions) ? collisions : [];
82
+ if (list.length === 0) return [];
83
+ const rows = list.map((c) => {
84
+ const scraped = renderScrapeAttribution(c.attribution);
85
+ return `| \`${c.slugs[0]}\` + \`${c.slugs[1]}\` | ${c.paths
86
+ .map((p) => `\`${p}\``)
87
+ .join(', ')} | ${c.source} | ${scraped ? `\`${scraped}\`` : '—'} |`;
88
+ });
89
+ const scrapedOnly = list.filter(
90
+ (c) => c.source === OVERLAP_SOURCES.SCRAPED,
91
+ ).length;
92
+ return [
93
+ '',
94
+ `#### ⚠️ Predicted serialisation (${list.length} same-order pair(s))`,
95
+ '',
96
+ '| Stories | Colliding paths | Overlap source | Scraped from |',
97
+ '| --- | --- | --- | --- |',
98
+ ...rows,
99
+ '',
100
+ '_The dispatcher compares the **evidence-widened** footprint — declared ' +
101
+ '`changes[]` plus every path named in the title, `## Spec` and the rest ' +
102
+ 'of the body — so these pairs are shown in one order above but will be ' +
103
+ `dispatched one at a time. ${scrapedOnly} pair(s) collide only on ` +
104
+ 'scraped paths; the "Scraped from" column names the field each such ' +
105
+ 'path was read out of, so a shared `## Verify` command is ' +
106
+ 'distinguishable from a genuine unpredicted edit target. The guard is ' +
107
+ 'deliberately not narrowed to `changes[]`: the declaration is a lower ' +
108
+ 'bound (Story #4875) and under-serialising is the worse failure._',
109
+ ];
110
+ }