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
@@ -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';
@@ -88,6 +88,7 @@ import {
88
88
  PLAN_SUMMARY_COMMENT_TYPE,
89
89
  } from './summary.js';
90
90
  import { closeSupersededTickets } from './supersede-ops.js';
91
+ import { predictWaveSerialisation } from './wave-serialisation.js';
91
92
 
92
93
  /** Checkpoint schema version written on each Story's story-plan-state. */
93
94
  const PLAN_CHECKPOINT_SCHEMA_VERSION_V2 = 2;
@@ -212,6 +213,7 @@ async function runSupersedePhase(args) {
212
213
  reason: `phase-error: ${err.message}`,
213
214
  closed: [],
214
215
  planned: [],
216
+ epicRollup: { closed: [], pending: [] },
215
217
  skipped: [],
216
218
  failed: (args.sourceTicketIds ?? []).map((ticket) => ({
217
219
  ticket,
@@ -831,6 +833,17 @@ export async function runPlanPersist({
831
833
  })),
832
834
  );
833
835
 
836
+ // Story #5265: the table says which Stories share an order; the dispatcher
837
+ // decides which of those actually run together, and it decides on the
838
+ // evidence-widened footprint. Run its own predicate over the assembled
839
+ // bodies — the exact artifact the tick will read back off GitHub — so the
840
+ // comment names the serialisation instead of promising parallelism the
841
+ // next tick refuses. `tempRoot` is threaded for the same reason the tick
842
+ // threads it: the scrape must ignore this project's scratch root.
843
+ const waveCollisions = predictWaveSerialisation(waveTable, stories, {
844
+ tempRoot: getPaths(config).tempRoot,
845
+ });
846
+
834
847
  // Story #4541: `readPlanMetrics` is declared `(epicId, config)` but was
835
848
  // called with `config` first, so the ledger path resolver received the
836
849
  // config object as an `epicId` and threw its guard on every single run —
@@ -860,6 +873,7 @@ export async function runPlanPersist({
860
873
  // collisions the conflict passes found belong on the same surface, or the
861
874
  // promise is the only half anyone reads.
862
875
  conflictFindings: assembledConflicts,
876
+ waveCollisions,
863
877
  });
864
878
 
865
879
  if (!dryRun) {
@@ -890,6 +904,10 @@ export async function runPlanPersist({
890
904
  stories,
891
905
  created,
892
906
  sourceTicketIds,
907
+ // Story #5280 — closing a source ticket is a child state change, so the
908
+ // phase re-derives the container above it. It needs the config the rollup
909
+ // reads its board and operator handle from.
910
+ config,
893
911
  dryRun,
894
912
  closeSuperseded,
895
913
  });
@@ -915,6 +933,11 @@ export async function runPlanPersist({
915
933
  reachability,
916
934
  freshness,
917
935
  waveTable,
936
+ // Story #5265 AC-2/AC-4: both halves of what persist concluded but used
937
+ // to keep to itself — the `refactors-existing` declarations it rewrote,
938
+ // and the same-order pairs the dispatcher will serialize.
939
+ assumptionNormalizations: validated.normalizations ?? [],
940
+ waveCollisions,
918
941
  supersede,
919
942
  epic: containerEpic,
920
943
  };
@@ -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._`,
@@ -28,6 +28,15 @@
28
28
  * partial failure reports which tickets were and were not closed so the
29
29
  * operator can finish by hand.
30
30
  *
31
+ * The close also strips the source ticket's `agent::*` label (Story #5255).
32
+ * A retired ticket has no agent state, and the one it kept was read as live
33
+ * work: `agent::blocked` is the state a Story must be in to be re-planned, so
34
+ * this path closes exactly the tickets carrying it, and a closed-but-blocked
35
+ * child pinned its container Epic open on every rollup thereafter. The
36
+ * derivation in `ticketing/bulk.js` now ignores closed children's labels too —
37
+ * that half covers the tickets already closed and the ones closed by hand;
38
+ * this one stops new ones being written.
39
+ *
31
40
  * Idempotency is keyed off the `superseded-by` structured-comment marker
32
41
  * (`upsertStructuredComment`), not a bare `postComment`, so a re-run cannot
33
42
  * double-comment.
@@ -36,10 +45,12 @@
36
45
  */
37
46
 
38
47
  import { Logger } from '../../Logger.js';
48
+ import { AGENT_LABELS } from '../../label-constants.js';
39
49
  import {
40
50
  concurrentMap,
41
51
  FANOUT_CONCURRENCY,
42
52
  } from '../../util/concurrent-map.js';
53
+ import { rollUpEpicForStory } from '../epic-rollup.js';
43
54
  import { upsertStructuredComment } from '../ticketing.js';
44
55
 
45
56
  /** Structured-comment type marking a source issue as superseded. */
@@ -55,6 +66,40 @@ const SUPERSEDED_BY_COMMENT_TYPE = 'superseded-by';
55
66
  */
56
67
  export const SUPERSEDE_CLOSE_REASON = 'not_planned';
57
68
 
69
+ /**
70
+ * Every `agent::*` label, as the set the supersede close strips.
71
+ *
72
+ * A retired ticket has no agent state. `agent::done` would be the wrong
73
+ * substitute — the work was re-planned, never delivered — so the label is
74
+ * removed rather than rewritten, and the ticket ends carrying only its
75
+ * `type::`/domain labels and the supersede comment that explains it.
76
+ */
77
+ const AGENT_STATE_LABELS = Object.freeze(Object.values(AGENT_LABELS));
78
+
79
+ /**
80
+ * The single `updateTicket` mutation that retires a source ticket.
81
+ *
82
+ * Closing and clearing the state ride one write: two calls could leave the
83
+ * ticket closed but still wearing `agent::blocked`, which is the shape that
84
+ * pinned a container Epic open forever (Story #5255).
85
+ *
86
+ * A ticket with no `agent::*` label gets the bare close, unchanged from before
87
+ * that Story — `updateTicket` merges a `labels` mutation by reading the issue
88
+ * back, so an unconditional empty `remove` would buy a wasted round-trip per
89
+ * superseded ticket. `_ticketSnapshot` feeds that merge the copy
90
+ * `probeSourceTicket` already fetched.
91
+ *
92
+ * @param {{ labels?: unknown }} ticket The probe's fresh copy.
93
+ * @returns {object} Mutations for `provider.updateTicket`.
94
+ */
95
+ function supersedeCloseMutations(ticket) {
96
+ const close = { state: 'closed', state_reason: SUPERSEDE_CLOSE_REASON };
97
+ const labels = Array.isArray(ticket?.labels) ? ticket.labels : [];
98
+ const remove = AGENT_STATE_LABELS.filter((label) => labels.includes(label));
99
+ if (remove.length === 0) return close;
100
+ return { ...close, labels: { remove }, _ticketSnapshot: ticket };
101
+ }
102
+
58
103
  /**
59
104
  * Coerce one `supersedes[]` entry into `{ id, note }`.
60
105
  *
@@ -329,13 +374,22 @@ export function buildSupersedeCommentBody({
329
374
  /**
330
375
  * Resolve the live state of a source ticket.
331
376
  *
332
- * @returns {Promise<{ ok: true, state: string } | { ok: false, reason: string }>}
377
+ * The ticket itself rides along so the close can strip the `agent::*` label
378
+ * without a second read: `updateTicket`'s label merge takes a
379
+ * `_ticketSnapshot` for exactly this, and this probe has already paid for the
380
+ * fresh copy.
381
+ *
382
+ * @returns {Promise<{ ok: true, state: string, ticket: object } | { ok: false, reason: string }>}
333
383
  */
334
384
  async function probeSourceTicket(provider, id) {
335
385
  try {
336
386
  const ticket = await provider.getTicket(id, { fresh: true });
337
387
  if (!ticket) return { ok: false, reason: 'not-found' };
338
- return { ok: true, state: String(ticket.state ?? 'open').toLowerCase() };
388
+ return {
389
+ ok: true,
390
+ state: String(ticket.state ?? 'open').toLowerCase(),
391
+ ticket,
392
+ };
339
393
  } catch (err) {
340
394
  return { ok: false, reason: `inaccessible: ${err.message}` };
341
395
  }
@@ -370,10 +424,7 @@ async function closeOneSupersededTicket({
370
424
  sourceTicketIds,
371
425
  }),
372
426
  );
373
- await provider.updateTicket(id, {
374
- state: 'closed',
375
- state_reason: SUPERSEDE_CLOSE_REASON,
376
- });
427
+ await provider.updateTicket(id, supersedeCloseMutations(probe.ticket));
377
428
  return { outcome: 'closed' };
378
429
  } catch (err) {
379
430
  return { outcome: 'failed', reason: err.message };
@@ -394,6 +445,9 @@ async function closeOneSupersededTicket({
394
445
  * slug is the only identifier that means anything before the writes land.
395
446
  * @property {Array<{ ticket: number, reason: string }>} skipped
396
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.
397
451
  */
398
452
 
399
453
  function emptyReport(overrides) {
@@ -405,10 +459,53 @@ function emptyReport(overrides) {
405
459
  planned: [],
406
460
  skipped: [],
407
461
  failed: [],
462
+ epicRollup: { closed: [], pending: [] },
408
463
  ...overrides,
409
464
  };
410
465
  }
411
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
+
412
509
  /**
413
510
  * Comment on and close every superseded source ticket.
414
511
  *
@@ -421,6 +518,7 @@ function emptyReport(overrides) {
421
518
  * @param {Array<{ slug: string, supersedes: Array<{ id: number, note: string|null }> }>} args.stories
422
519
  * @param {Array<{ slug: string, id: number, title: string }>} args.created
423
520
  * @param {number[]} args.sourceTicketIds
521
+ * @param {object} [args.config] Resolved `.agentrc.json`, for the Epic rollup.
424
522
  * @param {boolean} [args.dryRun=false]
425
523
  * @param {boolean} [args.closeSuperseded=true]
426
524
  * @returns {Promise<SupersedeReport>}
@@ -430,6 +528,7 @@ export async function closeSupersededTickets({
430
528
  stories,
431
529
  created,
432
530
  sourceTicketIds,
531
+ config,
433
532
  dryRun = false,
434
533
  closeSuperseded = true,
435
534
  }) {
@@ -482,6 +581,14 @@ export async function closeSupersededTickets({
482
581
  recordSupersedeOutcome(report, units[index], result);
483
582
  });
484
583
 
584
+ if (report.closed.length > 0) {
585
+ report.epicRollup = await rollUpContainersFor({
586
+ closedIds: report.closed,
587
+ provider,
588
+ config,
589
+ });
590
+ }
591
+
485
592
  logSupersedeReport(report);
486
593
  return report;
487
594
  }
@@ -563,4 +670,10 @@ function logSupersedeReport(report) {
563
670
  'close it by hand.',
564
671
  );
565
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
+ }
566
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
+ }