mandrel 2.25.0 → 2.26.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 (131) hide show
  1. package/.agents/agents/acceptance-critic.md +10 -6
  2. package/.agents/audit-checklists/baselines.md +21 -0
  3. package/.agents/docs/quality-gates.md +80 -18
  4. package/.agents/docs/workflows.md +3 -1
  5. package/.agents/instructions.md +1 -1
  6. package/.agents/schemas/audit-rules.json +15 -0
  7. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  8. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  9. package/.agents/schemas/baselines/crap.schema.json +8 -0
  10. package/.agents/schemas/model-attribution.schema.json +4 -0
  11. package/.agents/scripts/acceptance-eval.js +89 -6
  12. package/.agents/scripts/audit-baselines.js +136 -0
  13. package/.agents/scripts/check-arch-cycles.js +12 -93
  14. package/.agents/scripts/check-baseline-drift.js +16 -3
  15. package/.agents/scripts/check-baselines.js +19 -3
  16. package/.agents/scripts/check-cyclomatic.js +214 -0
  17. package/.agents/scripts/check-schema-references.js +392 -0
  18. package/.agents/scripts/check-test-temp-hygiene.js +38 -1
  19. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  20. package/.agents/scripts/diagnose-friction.js +85 -19
  21. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  22. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  23. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  24. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  25. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  26. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  27. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  28. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  29. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  30. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  31. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  32. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  33. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  34. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  35. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  36. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  37. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  38. package/.agents/scripts/lib/baselines/git-base.js +26 -4
  39. package/.agents/scripts/lib/baselines/kinds/crap.js +112 -15
  40. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  41. package/.agents/scripts/lib/baselines/scope.js +39 -90
  42. package/.agents/scripts/lib/baselines/writer.js +16 -11
  43. package/.agents/scripts/lib/changed-files.js +8 -1
  44. package/.agents/scripts/lib/cli-args.js +115 -1
  45. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  46. package/.agents/scripts/lib/crap-engine.js +32 -13
  47. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  48. package/.agents/scripts/lib/crap-utils.js +13 -0
  49. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  50. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  51. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  52. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  53. package/.agents/scripts/lib/git-utils.js +136 -80
  54. package/.agents/scripts/lib/import-graph.js +156 -0
  55. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  56. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  57. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  58. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  59. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  60. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  61. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  62. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  63. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  64. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  65. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  66. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +209 -109
  67. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +48 -12
  68. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  69. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  70. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  71. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  72. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  73. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  74. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +29 -3
  75. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  76. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  77. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  78. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +245 -140
  79. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  80. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  81. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  82. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  83. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  84. package/.agents/scripts/lib/story-adjacency.js +3 -3
  85. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  86. package/.agents/scripts/lib/test-tiers.js +11 -2
  87. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  88. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  89. package/.agents/scripts/lib/wave-runner/live-probe.js +24 -14
  90. package/.agents/scripts/lib/wave-runner/ready-set.js +189 -42
  91. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +4 -10
  92. package/.agents/scripts/lib/workers/crap-worker.js +2 -10
  93. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  94. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  95. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  96. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  97. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  98. package/.agents/scripts/lib/worktree-manager.js +14 -0
  99. package/.agents/scripts/plan-run-epilogue.js +17 -5
  100. package/.agents/scripts/providers/github/tickets.js +33 -10
  101. package/.agents/scripts/provision-git-hooks.js +85 -0
  102. package/.agents/scripts/quality-preview.js +112 -28
  103. package/.agents/scripts/resolve-stories.js +4 -1
  104. package/.agents/scripts/run-coverage.js +86 -35
  105. package/.agents/scripts/run-lint.js +20 -0
  106. package/.agents/scripts/run-tests.js +26 -36
  107. package/.agents/scripts/single-story-close.js +28 -2
  108. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  109. package/.agents/scripts/stories-wave-tick.js +214 -38
  110. package/.agents/scripts/update-coverage-baseline.js +34 -4
  111. package/.agents/scripts/update-duplication-baseline.js +209 -83
  112. package/.agents/scripts/validate-docs-freshness.js +1 -0
  113. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  114. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  115. package/.agents/skills/skills.index.json +2 -2
  116. package/.agents/workflows/audit-baselines.md +289 -0
  117. package/.agents/workflows/audit-navigability.md +5 -4
  118. package/.agents/workflows/deliver.md +13 -4
  119. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  120. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  121. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  122. package/.agents/workflows/helpers/deliver-reference.md +77 -1
  123. package/.agents/workflows/helpers/deliver-story-reference.md +47 -6
  124. package/.agents/workflows/helpers/plan-reference.md +15 -5
  125. package/.agents/workflows/memory-consolidate.md +116 -0
  126. package/.agents/workflows/plan.md +3 -0
  127. package/README.md +13 -6
  128. package/docs/CHANGELOG.md +64 -0
  129. package/package.json +9 -4
  130. package/.agents/schemas/friction-event.schema.json +0 -56
  131. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -0,0 +1,103 @@
1
+ /**
2
+ * parse-id-list — expand a Story-id list that may contain dash ranges.
3
+ *
4
+ * Operators name a contiguous span of Stories the way they read one — as a
5
+ * range: `/deliver 4922 - 4926`. Enumerating it by hand is the kind of
6
+ * transcription step that silently drops or invents an id, so the range is a
7
+ * first-class shape of every delivery id list rather than something the host
8
+ * expands from prose.
9
+ *
10
+ * Accepted tokens, comma-separated:
11
+ * - a single id, with an optional `#` — `4922`, `#4922`
12
+ * - an inclusive range — `4922-4926`, `4922 - 4926`, `#4922-#4926`
13
+ * (hyphen-minus, en dash, or em dash; whitespace around it is fine)
14
+ *
15
+ * Everything else is a hard error, never a silent drop: a wrong id list
16
+ * co-dispatches against the wrong graph, so it must fail where it is typed.
17
+ * Two range-specific guards exist for the same reason — a backwards range is
18
+ * refused rather than expanded to nothing, and a span above `MAX_RANGE_SPAN`
19
+ * is refused rather than resolving thousands of issues off a typo.
20
+ */
21
+
22
+ /**
23
+ * Inclusive-span ceiling for a single range token. Generous against any real
24
+ * plan run (a handful of Stories) and tight enough that `1-4926` is caught as
25
+ * the typo it is rather than fanning out into a live resolution sweep.
26
+ *
27
+ * Deliberately module-private: the cap is a published contract
28
+ * (`helpers/deliver-reference.md` § Ranges), so a test that imported it could
29
+ * not notice the number silently moving out from under the doc.
30
+ */
31
+ const MAX_RANGE_SPAN = 50;
32
+
33
+ /** Hyphen-minus, en dash, em dash — whichever the operator's keyboard emits. */
34
+ const DASH = '[-–—]';
35
+ const SINGLE_RE = /^#?(\d+)$/;
36
+ const RANGE_RE = new RegExp(`^#?(\\d+)\\s*${DASH}\\s*#?(\\d+)$`);
37
+
38
+ /**
39
+ * Parse a comma-separated Story-id list, expanding any `A-B` range token.
40
+ *
41
+ * Absent or empty input is not an error here — it yields an empty list, and
42
+ * the caller decides whether that is a usage error (`--ids`) or a legitimate
43
+ * empty set (`--done`).
44
+ *
45
+ * @param {string|undefined|null} raw
46
+ * @param {object} [options]
47
+ * @param {string} [options.flag] Flag name, for the error message.
48
+ * @param {string} [options.prefix] Message prefix, for the caller's log tag.
49
+ * @param {number} [options.maxSpan] Inclusive-span ceiling per range token.
50
+ * @returns {{ ids: number[]|null, error: string|null }}
51
+ */
52
+ export function expandIdList(raw, options = {}) {
53
+ const { flag = '--ids', prefix = '', maxSpan = MAX_RANGE_SPAN } = options;
54
+ const fail = (message) => ({ ids: null, error: `${prefix}${message}` });
55
+
56
+ const ids = [];
57
+ const seen = new Set();
58
+ const push = (n) => {
59
+ if (seen.has(n)) return;
60
+ seen.add(n);
61
+ ids.push(n);
62
+ };
63
+
64
+ for (const token of String(raw ?? '').split(',')) {
65
+ const trimmed = token.trim();
66
+ if (trimmed === '') continue;
67
+
68
+ const range = RANGE_RE.exec(trimmed);
69
+ if (range) {
70
+ const start = Number(range[1]);
71
+ const end = Number(range[2]);
72
+ if (start <= 0 || end <= 0) {
73
+ return fail(
74
+ `${flag} range "${trimmed}" must use positive issue numbers.`,
75
+ );
76
+ }
77
+ if (end < start) {
78
+ return fail(
79
+ `${flag} range "${trimmed}" runs backwards — write it low-to-high (e.g. 4922-4926).`,
80
+ );
81
+ }
82
+ const span = end - start + 1;
83
+ if (span > maxSpan) {
84
+ return fail(
85
+ `${flag} range "${trimmed}" spans ${span} ids, above the ${maxSpan}-id cap. Narrow it, or list the ids.`,
86
+ );
87
+ }
88
+ for (let n = start; n <= end; n++) push(n);
89
+ continue;
90
+ }
91
+
92
+ const single = SINGLE_RE.exec(trimmed);
93
+ const n = single ? Number(single[1]) : Number.NaN;
94
+ if (!Number.isInteger(n) || n <= 0) {
95
+ return fail(
96
+ `${flag} must be a comma-separated list of positive issue numbers or A-B ranges (got "${trimmed}").`,
97
+ );
98
+ }
99
+ push(n);
100
+ }
101
+
102
+ return { ids, error: null };
103
+ }
@@ -2,7 +2,7 @@
2
2
  * lib/wave-runner/live-probe.js — the state-probing adapter that feeds the
3
3
  * ready-set kernel from live GitHub state.
4
4
  *
5
- * `selectReadySet` (`./ready-set.js`) is deliberately a pure, side-effect-free
5
+ * `planReadySet` (`./ready-set.js`) is deliberately a pure, side-effect-free
6
6
  * kernel: callers hand it the live Story records, the done set, and the
7
7
  * in-flight count, and it decides. Until now the only adapter was the
8
8
  * flag-driven one (`stories-wave-tick.js --dag/--done/--in-flight`), which
@@ -41,7 +41,7 @@
41
41
  * "waiting" (Story #4601).
42
42
  *
43
43
  * It is an **adapter, not a kernel change**: it gathers inputs and hands them
44
- * to `selectReadySet` unchanged. The kernel stays pure and flag-driven, and
44
+ * to `planReadySet` unchanged. The kernel stays pure and flag-driven, and
45
45
  * the legacy flag mode stays byte-compatible.
46
46
  *
47
47
  * The graph resolution is **not** reimplemented here — it reuses
@@ -217,7 +217,7 @@ export function createProbeContext({
217
217
 
218
218
  /**
219
219
  * Probe live state for a set of Story ids and return the exact inputs
220
- * `selectReadySet` consumes.
220
+ * `planReadySet` consumes.
221
221
  *
222
222
  * Mirrors `resolve-stories.js`'s two-pass envelope build: a provisional pass
223
223
  * yields the DAG whose foreign dependency ids are then resolved against live
@@ -239,7 +239,7 @@ export function createProbeContext({
239
239
  * skipped (the probe never fails closed).
240
240
  * @param {(msg: string) => void} [args.warn]
241
241
  * Each returned node carries its **live labels**. That is load-bearing, not
242
- * decoration: `selectReadySet` classifies from labels, so a node stripped of
242
+ * decoration: `planReadySet` classifies from labels, so a node stripped of
243
243
  * them reads as `ready` and an `agent::executing` Story gets re-dispatched
244
244
  * onto a second branch while its first run is still going. The resolver's DAG
245
245
  * projection (`{id, dependsOn, files}`) drops labels because flag mode's
@@ -247,6 +247,7 @@ export function createProbeContext({
247
247
  *
248
248
  * @returns {Promise<{
249
249
  * nodes: Array<{id: number, dependsOn: number[], files: string[], body: string, labels: string[]}>,
250
+ * inFlightRecords: Array<{id: number, dependsOn: number[], files: string[], body: string, labels: string[]}>,
250
251
  * doneIds: Set<number>,
251
252
  * inFlight: number,
252
253
  * blockedIds: number[],
@@ -284,7 +285,7 @@ export async function probeLiveState({
284
285
  const labelsById = new Map(stories.map((s) => [s.id, s.labels ?? []]));
285
286
  // Bodies ride along with the node so the co-dispatch guard can widen a
286
287
  // declared footprint from the paths a Story's own text names (Story #4875).
287
- // They are consumed in-process by `selectReadySet` and never serialized into
288
+ // They are consumed in-process by `planReadySet` and never serialized into
288
289
  // the beat envelope, which stays a list of ids.
289
290
  const bodyById = new Map(stories.map((s) => [s.id, s.body ?? '']));
290
291
  const inFlightIds = deriveInFlightIds(stories, dispatched);
@@ -294,15 +295,24 @@ export async function probeLiveState({
294
295
  // never dispatched by this run.
295
296
  const foreignHeld = deriveForeignHeld(stories, self, warn);
296
297
  for (const id of foreignHeld.keys()) inFlightIds.add(id);
298
+ const nodes = envelope.dag.map((node) => ({
299
+ ...node,
300
+ body: bodyById.get(node.id) ?? '',
301
+ labels: projectInFlightLabels(
302
+ labelsById.get(node.id) ?? [],
303
+ inFlightIds.has(node.id),
304
+ ),
305
+ }));
297
306
  return {
298
- nodes: envelope.dag.map((node) => ({
299
- ...node,
300
- body: bodyById.get(node.id) ?? '',
301
- labels: projectInFlightLabels(
302
- labelsById.get(node.id) ?? [],
303
- inFlightIds.has(node.id),
304
- ),
305
- })),
307
+ nodes,
308
+ // The same nodes, narrowed to the Stories occupying a slot. `inFlight` is
309
+ // only a count, so it can shrink capacity but can never stop a Story
310
+ // admitted now from sharing files with one dispatched on an earlier beat
311
+ // and still implementing. These records carry the `files[]` and `body` the
312
+ // co-dispatch guard needs, so the kernel can RESERVE those footprints
313
+ // rather than merely count them (Story #4950). No extra fetch: this is a
314
+ // projection of what the probe already read.
315
+ inFlightRecords: nodes.filter((node) => inFlightIds.has(node.id)),
306
316
  doneIds: new Set(envelope.done),
307
317
  inFlight: inFlightIds.size,
308
318
  blockedIds: deriveBlockedIds(stories),
@@ -316,7 +326,7 @@ export async function probeLiveState({
316
326
  *
317
327
  * This is the load-bearing half of the dispatch-window fix, and it is why
318
328
  * `inFlight` alone is not enough. The two inputs do **different** jobs inside
319
- * `selectReadySet`:
329
+ * `planReadySet`:
320
330
  *
321
331
  * - `inFlight` is only a **count**. It reserves capacity (`slots = cap −
322
332
  * inFlight`) and nothing more.
@@ -19,24 +19,36 @@
19
19
  * `stories-wave-tick.js` adapter wires this core; this module does not
20
20
  * modify that CLI surface.
21
21
  *
22
- * Three exports:
22
+ * The scheduling surface:
23
23
  * - `classifyStory(story)` — live-label classifier mapping a Story
24
24
  * record's labels + issue state to one of `done | blocked | executing |
25
25
  * ready`. Mirrors the done-predicate this module uses
26
26
  * (`agent::done` OR closed issue) so a Story closed manually through
27
27
  * the GitHub UI is recognised as done.
28
- * - `storiesOverlap(a, b)` — the file-overlap co-dispatch guard: true
29
- * when two Stories' file footprints intersect. Two Stories that would
30
- * touch the same file MUST NOT be dispatched onto parallel
31
- * `story-<id>` branches in the same beat (they would race the same
32
- * path and produce a merge conflict at close). The comparison runs over
28
+ * - `storiesOverlap(a, b)` — the **beat-local** file-overlap co-dispatch
29
+ * guard: true when two Stories' file footprints intersect. Two Stories
30
+ * that would touch the same file MUST NOT be admitted onto parallel
31
+ * `story-<id>` branches on the same beat (they would race the same path
32
+ * and produce a merge conflict at close). The comparison runs over
33
33
  * the **widened** footprint (`storyWidenedFootprint`) — a declared
34
34
  * `changes[]` is treated as a lower bound and widened from the paths the
35
35
  * Story's own text names, because a guard that trusts a prediction cannot
36
36
  * prevent the collision nobody predicted (Story #4875).
37
- * - `selectReadySet({ stories, doneIds, inFlight, globalCap })` — the
38
- * scheduler. Returns the deterministic, overlap-free set of ready
39
- * Stories, capped at `globalCap − inFlight`.
37
+ * - `planReadySet({ stories, doneIds, inFlight, globalCap,
38
+ * inFlightRecords })` — the scheduler. Returns the deterministic,
39
+ * overlap-free dispatch set (capped at `globalCap − inFlight`) **and**
40
+ * the Stories it withheld because a **concrete** path in their footprint
41
+ * is reserved by a Story still in flight from an earlier beat.
42
+ *
43
+ * The two guards deliberately draw the glob/UNKNOWN class differently
44
+ * (Story #4960). Within a beat an unknown-width footprint overlaps
45
+ * everything, because admitting two Stories whose real widths are unknown is
46
+ * the collision the guard exists to prevent. Across beats it reserves
47
+ * nothing: an in-flight Story's window spans its whole implementation, so a
48
+ * glob that reserved cross-beat would withhold every other Story for
49
+ * minutes-to-hours — and `resolve-stories.js` substitutes the UNKNOWN
50
+ * sentinel for any body it cannot parse, so a single malformed Story body
51
+ * would collapse an N-Story run to fully serial.
40
52
  *
41
53
  * Adjacency is re-derived from the supplied records via the shared
42
54
  * `buildStoryAdjacency` builder (`lib/story-adjacency.js`) — the same
@@ -226,11 +238,29 @@ function storyWidenedFootprint(story) {
226
238
  }
227
239
 
228
240
  /**
229
- * File-overlap co-dispatch guard. Returns `true` when two Stories' declared
230
- * file footprints intersect — meaning they would race the same file if
231
- * dispatched onto parallel `story-<id>` branches in the same beat. Two
232
- * Stories that overlap MUST NOT both appear in one dispatch set; one is
233
- * withheld until the other clears.
241
+ * Both Stories' widened footprints, or `null` when either is empty.
242
+ *
243
+ * **An empty footprint means "no known overlap"**, so both guards below
244
+ * short-circuit to `false` on one. This is permissive by necessity: a Story
245
+ * with no declared footprint and no path evidence in its text carries no
246
+ * information, and withholding on absence would serialize every run.
247
+ *
248
+ * @param {StoryRecord} a
249
+ * @param {StoryRecord} b
250
+ * @returns {[Set<string>, Set<string>]|null}
251
+ */
252
+ function widenedFootprintPair(a, b) {
253
+ const fa = storyWidenedFootprint(a);
254
+ if (fa.size === 0) return null;
255
+ const fb = storyWidenedFootprint(b);
256
+ if (fb.size === 0) return null;
257
+ return [fa, fb];
258
+ }
259
+
260
+ /**
261
+ * **Beat-local** file-overlap co-dispatch guard. Returns `true` when two
262
+ * Stories' file footprints intersect — meaning they would race the same file
263
+ * if both were admitted onto parallel `story-<id>` branches on this beat.
234
264
  *
235
265
  * Comparison runs over the **widened** footprint
236
266
  * ({@link storyWidenedFootprint}), not the declaration: a declared `changes[]`
@@ -238,32 +268,26 @@ function storyWidenedFootprint(story) {
238
268
  * collide were being co-dispatched whenever the collision was not predicted
239
269
  * (Story #4875).
240
270
  *
241
- * Two deliberate asymmetries:
271
+ * **A glob footprint overlaps EVERYTHING** → `true` (Story #4539/#4540).
272
+ * Comparison is exact-string, so a Story declaring `.agents/scripts/lib/**`
273
+ * would not match another declaring `.agents/scripts/lib/story-adjacency.js` —
274
+ * the guard would silently pass two Stories that genuinely race. Unknown width
275
+ * is not the same as no width: fail safe by serializing the rest of the beat.
242
276
  *
243
- * - **An empty footprint means "no known overlap"** `false`. A Story with
244
- * no declared footprint and no path evidence in its text is never
245
- * withheld. This is permissive by necessity: a genuinely unknown footprint
246
- * carries no information, and withholding on absence would serialize every
247
- * run.
248
- * - **A glob footprint overlaps EVERYTHING** → `true` (Story #4539/#4540).
249
- * Comparison is exact-string, so a Story declaring
250
- * `.agents/scripts/lib/**` would not match another declaring
251
- * `.agents/scripts/lib/story-adjacency.js` — the guard would silently
252
- * pass two Stories that genuinely race. Unknown width is not the same as
253
- * no width: fail safe by serializing.
277
+ * That fail-safe is **beat-local and stays that way**. The cross-beat
278
+ * reservation uses {@link reservesConcretePath} instead, because a beat is a
279
+ * moment and an in-flight window is an implementation (Story #4960).
254
280
  *
255
281
  * @param {StoryRecord} a
256
282
  * @param {StoryRecord} b
257
283
  * @returns {boolean}
258
284
  */
259
285
  export function storiesOverlap(a, b) {
260
- const fa = storyWidenedFootprint(a);
261
- if (fa.size === 0) return false;
262
- const fb = storyWidenedFootprint(b);
263
- if (fb.size === 0) return false;
286
+ const pair = widenedFootprintPair(a, b);
287
+ if (pair === null) return false;
288
+ const [fa, fb] = pair;
264
289
  for (const path of fa) {
265
- if (isGlobPath(path)) return true;
266
- if (fb.has(path)) return true;
290
+ if (isGlobPath(path) || fb.has(path)) return true;
267
291
  }
268
292
  for (const path of fb) {
269
293
  if (isGlobPath(path)) return true;
@@ -271,6 +295,42 @@ export function storiesOverlap(a, b) {
271
295
  return false;
272
296
  }
273
297
 
298
+ /**
299
+ * **Cross-beat** reservation guard: `true` only when the two widened
300
+ * footprints share a **concrete** (non-glob) path.
301
+ *
302
+ * A Story dispatched on an earlier beat holds its footprint for its entire
303
+ * implementation window, not for a moment, so the two guards cannot share a
304
+ * predicate (Story #4960):
305
+ *
306
+ * - A **concrete** shared path is a real, named collision — two branches
307
+ * editing `lib/foo.js` conflict at close whether the peer was admitted
308
+ * alongside or hours ago. It reserves, exactly as Story #4950 shipped.
309
+ * - A **glob** — or the UNKNOWN sentinel `resolve-stories.js` substitutes
310
+ * for an unparseable body — names no file. Reserving on it withheld every
311
+ * eligible Story against a single unknown-width blocker for that blocker's
312
+ * whole window, which is strictly worse than the serial run the
313
+ * reservation was meant to speed up. Glob paths are therefore skipped on
314
+ * both sides here; the beat-local {@link storiesOverlap} fail-safe is
315
+ * unchanged and still serializes them within a beat.
316
+ *
317
+ * Set intersection is symmetric, so scanning the reserving side alone finds
318
+ * every shared concrete path.
319
+ *
320
+ * @param {StoryRecord} held The in-flight Story holding the reservation.
321
+ * @param {StoryRecord} candidate The Story being considered for admission.
322
+ * @returns {boolean}
323
+ */
324
+ function reservesConcretePath(held, candidate) {
325
+ const pair = widenedFootprintPair(held, candidate);
326
+ if (pair === null) return false;
327
+ const [fa, fb] = pair;
328
+ for (const path of fa) {
329
+ if (!isGlobPath(path) && fb.has(path)) return true;
330
+ }
331
+ return false;
332
+ }
333
+
274
334
  /**
275
335
  * Select the set of Stories safe to dispatch on this beat.
276
336
  *
@@ -304,10 +364,12 @@ export function storiesOverlap(a, b) {
304
364
  * already occupying a slot (executing / closing / dispatched-not-yet-
305
365
  * labelled). When `slots <= 0`, the result is empty.
306
366
  * 5. **Overlap guard.** Greedily admit eligible Stories in ascending-id
307
- * order, skipping any whose file footprint overlaps an
308
- * already-admitted Story (`storiesOverlap`). A withheld Story stays
309
- * eligible and is naturally re-considered on the next beat once its
310
- * overlapping peer has cleared.
367
+ * order, skipping any whose file footprint overlaps a Story **already
368
+ * admitted this beat** ({@link storiesOverlap}) or shares a **concrete**
369
+ * path with a Story **still in flight from an earlier beat**
370
+ * (`inFlightRecords`, {@link reservesConcretePath}). A withheld Story
371
+ * stays eligible and is naturally re-considered on the next beat once
372
+ * the Story reserving its files has cleared.
311
373
  *
312
374
  * The result is deterministic: eligible Stories are considered in
313
375
  * ascending-id order, so the same inputs always yield the same set.
@@ -325,22 +387,37 @@ export function storiesOverlap(a, b) {
325
387
  * step 1 above). `false` keeps a foreign dependency as a gate
326
388
  * (standalone / operator-DAG semantics); `true` prunes foreign edges so
327
389
  * the DAG stays closed over the scheduled set (Epic semantics).
328
- * @returns {StoryRecord[]} The dispatch set: a subset of `stories`,
329
- * ascending by id, overlap-free, length `globalCap inFlight`.
390
+ * @param {StoryRecord[]} [args.inFlightRecords=[]] Records for the Stories
391
+ * already in flight, whose **concrete** footprint paths this beat must
392
+ * **reserve** rather than merely count. `inFlight` is a number and can only
393
+ * shrink capacity; without the records a Story admitted now can share files
394
+ * with one dispatched on an earlier beat and still implementing — a
395
+ * guaranteed merge conflict at close (Story #4950). A glob / UNKNOWN
396
+ * footprint reserves nothing (Story #4960). Callers that hold only ids (the
397
+ * `--dag`/`--in-flight` flag mode) pass nothing and get the pre-#4950
398
+ * same-beat-only behaviour.
399
+ * @returns {{ selected: StoryRecord[], withheldByInFlight: Array<{id: number, blockedBy: number}> }}
400
+ * `selected` is the dispatch set: a subset of `stories`, ascending by id,
401
+ * overlap-free, length ≤ `globalCap − inFlight`. `withheldByInFlight`
402
+ * names each eligible Story a reservation held back and the in-flight
403
+ * Story that holds it.
330
404
  */
331
- export function selectReadySet({
405
+ export function planReadySet({
332
406
  stories,
333
407
  doneIds = [],
334
408
  inFlight = 0,
335
409
  globalCap,
336
410
  dropForeign = false,
411
+ inFlightRecords = [],
337
412
  } = {}) {
338
413
  const records = Array.isArray(stories) ? stories : [];
339
414
  const cap = Number.isInteger(globalCap) ? globalCap : 0;
340
415
  const inFlightCount =
341
416
  Number.isInteger(inFlight) && inFlight > 0 ? inFlight : 0;
342
417
  const slots = Math.max(0, cap - inFlightCount);
343
- if (slots <= 0 || records.length === 0) return [];
418
+ if (slots <= 0 || records.length === 0) {
419
+ return { selected: [], withheldByInFlight: [] };
420
+ }
344
421
 
345
422
  // Step 1 — adjacency keyed by id. The `dropForeign` policy decides whether
346
423
  // a dependency on an id outside the supplied set gates the dependent
@@ -373,13 +450,83 @@ export function selectReadySet({
373
450
  }
374
451
 
375
452
  // Steps 4 + 5 — greedily admit up to `slots`, skipping file-overlap
376
- // collisions against the already-admitted set.
453
+ // collisions against the already-admitted set AND against the footprints
454
+ // reserved by Stories still in flight from an earlier beat.
455
+ return admitStories({
456
+ eligibleIds,
457
+ byId,
458
+ slots,
459
+ reserved: Array.isArray(inFlightRecords) ? inFlightRecords : [],
460
+ });
461
+ }
462
+
463
+ /**
464
+ * Greedily admit eligible Stories in ascending-id order under two distinct
465
+ * withholding rules, and report which ones a reservation held back.
466
+ *
467
+ * The two rules are distinct predicates, not one applied twice: the
468
+ * cross-beat reservation matches only a shared **concrete** path
469
+ * ({@link reservesConcretePath}), while the same-beat guard also serializes
470
+ * unknown-width footprints ({@link storiesOverlap}). See both for why.
471
+ *
472
+ * The reservation check runs **first**, so a Story racing both an in-flight
473
+ * Story and a same-beat peer is reported against the in-flight one: that is
474
+ * the longer-lived and more informative blocker (a Story that has been
475
+ * implementing for beats, not one merely admitted a moment ago), and checking
476
+ * it first is what makes the report complete — every withheld-by-reservation
477
+ * Story appears in it. Ordering cannot change `selected`: a candidate either
478
+ * rule rejects is skipped whichever runs first; only which list it is
479
+ * reported in depends on the order.
480
+ *
481
+ * @param {object} args
482
+ * @param {number[]} args.eligibleIds Ascending eligible Story ids.
483
+ * @param {Map<number, StoryRecord>} args.byId
484
+ * @param {number} args.slots Remaining dispatch capacity.
485
+ * @param {StoryRecord[]} args.reserved In-flight Story records.
486
+ * @returns {{ selected: StoryRecord[], withheldByInFlight: Array<{id: number, blockedBy: number}> }}
487
+ */
488
+ function admitStories({ eligibleIds, byId, slots, reserved }) {
377
489
  const selected = [];
490
+ const withheldByInFlight = [];
378
491
  for (const id of eligibleIds) {
379
492
  if (selected.length >= slots) break;
380
493
  const rec = byId.get(id);
494
+ const blockedBy = findInFlightBlocker(rec, id, reserved);
495
+ if (blockedBy !== null) {
496
+ withheldByInFlight.push({ id, blockedBy });
497
+ continue;
498
+ }
381
499
  if (selected.some((picked) => storiesOverlap(picked, rec))) continue;
382
500
  selected.push(rec);
383
501
  }
384
- return selected;
502
+ return { selected, withheldByInFlight };
503
+ }
504
+
505
+ /**
506
+ * The id of the in-flight Story whose widened footprint reserves a concrete
507
+ * path the candidate would race, or `null` when none does.
508
+ *
509
+ * Two records are skipped rather than treated as blockers:
510
+ *
511
+ * - **The candidate itself.** Probe mode hands the whole record set to both
512
+ * arguments, and an in-flight Story classifies `executing` rather than
513
+ * `ready`, so a candidate can never legitimately appear here — but a
514
+ * caller that double-lists one Story must not have it withhold itself.
515
+ * - **An unidentifiable record.** A withholding this function cannot name
516
+ * is one the envelope cannot explain, and an unexplained unfilled slot is
517
+ * the exact operator-facing failure this reservation exists to remove.
518
+ * Probe-mode records always carry an integer id, so this is defensive.
519
+ *
520
+ * @param {StoryRecord} candidate
521
+ * @param {number} candidateId
522
+ * @param {StoryRecord[]} reserved
523
+ * @returns {number|null}
524
+ */
525
+ function findInFlightBlocker(candidate, candidateId, reserved) {
526
+ for (const held of reserved) {
527
+ const heldId = storyIdOf(held);
528
+ if (heldId === null || heldId === candidateId) continue;
529
+ if (reservesConcretePath(held, candidate)) return heldId;
530
+ }
531
+ return null;
385
532
  }
@@ -47,6 +47,7 @@ import { parentPort } from 'node:worker_threads';
47
47
  import { finalizeMethodRows } from '../crap-engine.js';
48
48
  import { analyzeOnce } from '../crap-utils.js';
49
49
  import { prepareSourceForScoring } from '../transpile.js';
50
+ import { serveWorkerMessages } from './serve-worker-messages.js';
50
51
 
51
52
  /**
52
53
  * Pure handler for a single inbound worker message. Exported so unit tests
@@ -163,13 +164,6 @@ export function handleCombinedMiCrapWorkerMessage(msg, deps = {}) {
163
164
  });
164
165
  }
165
166
 
166
- if (parentPort) {
167
- parentPort.on('message', (msg) => {
168
- const out = handleCombinedMiCrapWorkerMessage(msg);
169
- if (out.kind === 'exit') {
170
- parentPort.close();
171
- return;
172
- }
173
- parentPort.postMessage(out.message);
174
- });
175
- }
167
+ serveWorkerMessages(parentPort, (msg) =>
168
+ handleCombinedMiCrapWorkerMessage(msg),
169
+ );
@@ -32,6 +32,7 @@
32
32
  import { parentPort } from 'node:worker_threads';
33
33
  import { calculateCrapForSource, finalizeMethodRows } from '../crap-engine.js';
34
34
  import { prepareSourceForScoring } from '../transpile.js';
35
+ import { serveWorkerMessages } from './serve-worker-messages.js';
35
36
 
36
37
  /**
37
38
  * Pure handler for a single inbound worker message. Exported so unit
@@ -161,13 +162,4 @@ export function handleCrapWorkerMessage(msg, _coverage, deps = {}) {
161
162
  };
162
163
  }
163
164
 
164
- if (parentPort) {
165
- parentPort.on('message', (msg) => {
166
- const out = handleCrapWorkerMessage(msg, null);
167
- if (out.kind === 'exit') {
168
- parentPort.close();
169
- return;
170
- }
171
- parentPort.postMessage(out.message);
172
- });
173
- }
165
+ serveWorkerMessages(parentPort, (msg) => handleCrapWorkerMessage(msg, null));
@@ -40,6 +40,7 @@ import {
40
40
  calculateReportForFile,
41
41
  } from '../maintainability-engine.js';
42
42
  import { transpileIfNeeded } from '../transpile.js';
43
+ import { serveWorkerMessages } from './serve-worker-messages.js';
43
44
 
44
45
  /**
45
46
  * Score a pre-sourced content string (Story #3696). Mirrors
@@ -125,13 +126,6 @@ export function handleMaintainabilityReportWorkerMessage(msg, deps = {}) {
125
126
  }
126
127
  }
127
128
 
128
- if (parentPort) {
129
- parentPort.on('message', (msg) => {
130
- const out = handleMaintainabilityReportWorkerMessage(msg);
131
- if (out.kind === 'exit') {
132
- parentPort.close();
133
- return;
134
- }
135
- parentPort.postMessage(out.message);
136
- });
137
- }
129
+ serveWorkerMessages(parentPort, (msg) =>
130
+ handleMaintainabilityReportWorkerMessage(msg),
131
+ );
@@ -21,6 +21,7 @@
21
21
 
22
22
  import { parentPort } from 'node:worker_threads';
23
23
  import { scoreFile } from '../maintainability-engine.js';
24
+ import { serveWorkerMessages } from './serve-worker-messages.js';
24
25
 
25
26
  /**
26
27
  * Pure handler for a single inbound worker message. Exported so unit
@@ -72,13 +73,6 @@ export function handleMaintainabilityWorkerMessage(msg, deps = {}) {
72
73
  }
73
74
  }
74
75
 
75
- if (parentPort) {
76
- parentPort.on('message', (msg) => {
77
- const out = handleMaintainabilityWorkerMessage(msg);
78
- if (out.kind === 'exit') {
79
- parentPort.close();
80
- return;
81
- }
82
- parentPort.postMessage(out.message);
83
- });
84
- }
76
+ serveWorkerMessages(parentPort, (msg) =>
77
+ handleMaintainabilityWorkerMessage(msg),
78
+ );
@@ -0,0 +1,35 @@
1
+ /**
2
+ * lib/workers/serve-worker-messages.js — the shared CPU-pool worker skeleton.
3
+ *
4
+ * Every worker under `lib/workers/` is a pure message handler plus the same
5
+ * eight-line `parentPort` wiring: subscribe, run the handler, close on an
6
+ * `exit` verdict, post the reply otherwise. That wiring was copied into all
7
+ * four workers; it lives here once instead (Story #4926).
8
+ *
9
+ * The handler contract is unchanged — see `lib/cpu-pool.js`:
10
+ * IN : `{ item: … }` — one unit of work
11
+ * `{ exit: true }` — drain & terminate
12
+ * OUT : `{ kind: 'exit' }` | `{ kind: 'reply', message: object }`
13
+ */
14
+
15
+ /**
16
+ * Wire a pure worker message handler onto a `worker_threads` port.
17
+ *
18
+ * A falsy `port` is a no-op, so a worker module imported directly by a unit
19
+ * test (where `parentPort` is `null`) installs no listener and stays pure.
20
+ *
21
+ * @param {import('node:worker_threads').MessagePort|null|undefined} port
22
+ * @param {(msg: unknown) => {kind: 'exit'} | {kind: 'reply', message: object}} handle
23
+ * @returns {void}
24
+ */
25
+ export function serveWorkerMessages(port, handle) {
26
+ if (!port) return;
27
+ port.on('message', (msg) => {
28
+ const out = handle(msg);
29
+ if (out.kind === 'exit') {
30
+ port.close();
31
+ return;
32
+ }
33
+ port.postMessage(out.message);
34
+ });
35
+ }