mandrel 2.24.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 (189) hide show
  1. package/.agents/agents/acceptance-critic.md +19 -21
  2. package/.agents/agents/auditor.md +16 -19
  3. package/.agents/agents/story-worker.md +41 -54
  4. package/.agents/audit-checklists/architecture.md +5 -1
  5. package/.agents/audit-checklists/baselines.md +21 -0
  6. package/.agents/audit-checklists/quality.md +3 -0
  7. package/.agents/docs/agentrc-reference.json +2 -1
  8. package/.agents/docs/configuration.md +2 -1
  9. package/.agents/docs/quality-gates.md +80 -18
  10. package/.agents/docs/workflows.md +4 -2
  11. package/.agents/instructions.md +1 -1
  12. package/.agents/rules/ci-remediation.md +68 -3
  13. package/.agents/schemas/agentrc.schema.json +6 -1
  14. package/.agents/schemas/audit-rules.json +15 -0
  15. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  16. package/.agents/schemas/baselines/baseline-envelope.schema.json +8 -0
  17. package/.agents/schemas/baselines/crap.schema.json +13 -1
  18. package/.agents/schemas/crap-report.schema.json +37 -0
  19. package/.agents/schemas/model-attribution.schema.json +4 -0
  20. package/.agents/scripts/acceptance-eval.js +124 -15
  21. package/.agents/scripts/audit-baselines.js +136 -0
  22. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  23. package/.agents/scripts/audit-to-stories.js +33 -3
  24. package/.agents/scripts/check-arch-cycles.js +12 -93
  25. package/.agents/scripts/check-baseline-drift.js +16 -3
  26. package/.agents/scripts/check-baselines.js +19 -3
  27. package/.agents/scripts/check-context-budget.js +95 -14
  28. package/.agents/scripts/check-cyclomatic.js +214 -0
  29. package/.agents/scripts/check-schema-references.js +392 -0
  30. package/.agents/scripts/check-test-temp-hygiene.js +121 -1
  31. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  32. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  33. package/.agents/scripts/deliver-light.js +3 -4
  34. package/.agents/scripts/deliver-recover.js +13 -0
  35. package/.agents/scripts/diagnose-friction.js +85 -19
  36. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  37. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  38. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  39. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  40. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  41. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  42. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  43. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  44. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  45. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  46. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  47. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  48. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  49. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  50. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  51. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  52. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  53. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  54. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  55. package/.agents/scripts/lib/baseline-loader.js +0 -0
  56. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  57. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  58. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  59. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  60. package/.agents/scripts/lib/baselines/kinds/crap.js +499 -57
  61. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  62. package/.agents/scripts/lib/baselines/reader.js +10 -0
  63. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  64. package/.agents/scripts/lib/baselines/scope.js +39 -90
  65. package/.agents/scripts/lib/baselines/writer.js +16 -7
  66. package/.agents/scripts/lib/changed-files.js +8 -1
  67. package/.agents/scripts/lib/cli-args.js +115 -1
  68. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  69. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  70. package/.agents/scripts/lib/crap-engine.js +124 -27
  71. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  72. package/.agents/scripts/lib/crap-utils.js +86 -13
  73. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  74. package/.agents/scripts/lib/env-loader.js +46 -16
  75. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  76. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  77. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  78. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  79. package/.agents/scripts/lib/findings/severity.js +80 -2
  80. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  81. package/.agents/scripts/lib/git-utils.js +136 -80
  82. package/.agents/scripts/lib/import-graph.js +156 -0
  83. package/.agents/scripts/lib/label-constants.js +17 -0
  84. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  85. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  86. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  87. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  88. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  89. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  90. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  91. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  92. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  93. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  94. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  95. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  96. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  97. package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
  98. package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
  99. package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
  100. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  101. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  102. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +216 -109
  103. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +61 -13
  104. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  105. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  106. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  107. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  108. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  109. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  110. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  111. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  112. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  113. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +125 -37
  114. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  115. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  116. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  117. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  118. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +267 -125
  119. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  120. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  121. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  122. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  123. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  124. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  125. package/.agents/scripts/lib/story-adjacency.js +3 -3
  126. package/.agents/scripts/lib/story-plan.js +137 -42
  127. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  128. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  129. package/.agents/scripts/lib/test-tiers.js +11 -2
  130. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  131. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  132. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  133. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  134. package/.agents/scripts/lib/wave-runner/live-probe.js +29 -13
  135. package/.agents/scripts/lib/wave-runner/ready-set.js +254 -43
  136. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +10 -13
  137. package/.agents/scripts/lib/workers/crap-worker.js +8 -13
  138. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  139. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  140. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  141. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  142. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  143. package/.agents/scripts/lib/worktree-manager.js +14 -0
  144. package/.agents/scripts/plan-run-epilogue.js +17 -5
  145. package/.agents/scripts/pr-watch-with-update.js +568 -213
  146. package/.agents/scripts/providers/github/tickets.js +33 -10
  147. package/.agents/scripts/provision-git-hooks.js +85 -0
  148. package/.agents/scripts/quality-preview.js +137 -28
  149. package/.agents/scripts/resolve-stories.js +4 -1
  150. package/.agents/scripts/run-coverage.js +86 -35
  151. package/.agents/scripts/run-lint.js +20 -0
  152. package/.agents/scripts/run-tests.js +36 -36
  153. package/.agents/scripts/single-story-close.js +34 -2
  154. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  155. package/.agents/scripts/single-story-init.js +7 -0
  156. package/.agents/scripts/stories-wave-tick.js +308 -47
  157. package/.agents/scripts/story-plan.js +65 -9
  158. package/.agents/scripts/update-coverage-baseline.js +34 -4
  159. package/.agents/scripts/update-crap-baseline.js +42 -4
  160. package/.agents/scripts/update-duplication-baseline.js +209 -83
  161. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  162. package/.agents/scripts/validate-docs-freshness.js +1 -0
  163. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  164. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  165. package/.agents/skills/skills.index.json +2 -2
  166. package/.agents/templates/single-story-body.md +16 -8
  167. package/.agents/workflows/audit-architecture.md +69 -13
  168. package/.agents/workflows/audit-baselines.md +289 -0
  169. package/.agents/workflows/audit-navigability.md +5 -4
  170. package/.agents/workflows/audit-quality.md +26 -0
  171. package/.agents/workflows/audit-to-stories.md +30 -4
  172. package/.agents/workflows/deliver.md +92 -97
  173. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  174. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  175. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  176. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  177. package/.agents/workflows/helpers/deliver-reference.md +95 -4
  178. package/.agents/workflows/helpers/deliver-story-reference.md +174 -52
  179. package/.agents/workflows/helpers/deliver-story.md +104 -119
  180. package/.agents/workflows/helpers/plan-reference.md +49 -5
  181. package/.agents/workflows/memory-consolidate.md +116 -0
  182. package/.agents/workflows/plan.md +62 -75
  183. package/README.md +13 -6
  184. package/docs/CHANGELOG.md +93 -0
  185. package/lib/cli/update.js +14 -11
  186. package/lib/cli/version-check.js +9 -1
  187. package/package.json +9 -4
  188. package/.agents/schemas/friction-event.schema.json +0 -56
  189. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -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,14 +239,15 @@ 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
246
246
  * caller tracked in-flight itself; probe mode must put them back.
247
247
  *
248
248
  * @returns {Promise<{
249
- * nodes: Array<{id: number, dependsOn: number[], files: string[], labels: string[]}>,
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[],
@@ -282,6 +283,11 @@ export async function probeLiveState({
282
283
  });
283
284
 
284
285
  const labelsById = new Map(stories.map((s) => [s.id, s.labels ?? []]));
286
+ // Bodies ride along with the node so the co-dispatch guard can widen a
287
+ // declared footprint from the paths a Story's own text names (Story #4875).
288
+ // They are consumed in-process by `planReadySet` and never serialized into
289
+ // the beat envelope, which stays a list of ids.
290
+ const bodyById = new Map(stories.map((s) => [s.id, s.body ?? '']));
285
291
  const inFlightIds = deriveInFlightIds(stories, dispatched);
286
292
  // A Story another operator's lease holds occupies a (global) dispatch slot
287
293
  // just like an in-flight one: fold it into the in-flight set so it is both
@@ -289,14 +295,24 @@ export async function probeLiveState({
289
295
  // never dispatched by this run.
290
296
  const foreignHeld = deriveForeignHeld(stories, self, warn);
291
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
+ }));
292
306
  return {
293
- nodes: envelope.dag.map((node) => ({
294
- ...node,
295
- labels: projectInFlightLabels(
296
- labelsById.get(node.id) ?? [],
297
- inFlightIds.has(node.id),
298
- ),
299
- })),
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)),
300
316
  doneIds: new Set(envelope.done),
301
317
  inFlight: inFlightIds.size,
302
318
  blockedIds: deriveBlockedIds(stories),
@@ -310,7 +326,7 @@ export async function probeLiveState({
310
326
  *
311
327
  * This is the load-bearing half of the dispatch-window fix, and it is why
312
328
  * `inFlight` alone is not enough. The two inputs do **different** jobs inside
313
- * `selectReadySet`:
329
+ * `planReadySet`:
314
330
  *
315
331
  * - `inFlight` is only a **count**. It reserves capacity (`slots = cap −
316
332
  * inFlight`) and nothing more.
@@ -19,20 +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' declared file footprints intersect. Two Stories
30
- * that would 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).
33
- * - `selectReadySet({ stories, doneIds, inFlight, globalCap })`the
34
- * scheduler. Returns the deterministic, overlap-free set of ready
35
- * Stories, capped at `globalCap inFlight`.
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
+ * the **widened** footprint (`storyWidenedFootprint`) — a declared
34
+ * `changes[]` is treated as a lower bound and widened from the paths the
35
+ * Story's own text names, because a guard that trusts a prediction cannot
36
+ * prevent the collision nobody predicted (Story #4875).
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.
36
52
  *
37
53
  * Adjacency is re-derived from the supplied records via the shared
38
54
  * `buildStoryAdjacency` builder (`lib/story-adjacency.js`) — the same
@@ -169,37 +185,109 @@ function isGlobPath(path) {
169
185
  }
170
186
 
171
187
  /**
172
- * File-overlap co-dispatch guard. Returns `true` when two Stories' declared
173
- * file footprints intersect meaning they would race the same file if
174
- * dispatched onto parallel `story-<id>` branches in the same beat. Two
175
- * Stories that overlap MUST NOT both appear in one dispatch set; one is
176
- * withheld until the other clears.
177
- *
178
- * Two deliberate asymmetries:
179
- *
180
- * - **An empty footprint means "no known overlap"** → `false`. A Story that
181
- * declares no files is never withheld. This is permissive by necessity:
182
- * an undeclared footprint carries no information, and withholding on
183
- * absence would serialize every run.
184
- * - **A glob footprint overlaps EVERYTHING** `true` (Story #4539/#4540).
185
- * Comparison is exact-string, so a Story declaring
186
- * `.agents/scripts/lib/**` would not match another declaring
187
- * `.agents/scripts/lib/story-adjacency.js` the guard would silently
188
- * pass two Stories that genuinely race. Unknown width is not the same as
189
- * no width: fail safe by serializing.
188
+ * Repo-relative file paths as they appear in Story prose: at least one `/`
189
+ * separator and a short file extension. Deliberately narrow a token has to
190
+ * look like a real path before it can widen a footprint and withhold a Story.
191
+ */
192
+ const PROSE_PATH_RE = /(?:[\w.@~-]+\/)+[\w.@-]+\.[A-Za-z0-9]{1,6}/g;
193
+
194
+ /**
195
+ * Scrape file paths a Story's **text** mentions but its `changes[]` never
196
+ * declared (Story #4875).
197
+ *
198
+ * The declared footprint is a planner's *prediction*, and it is systematically
199
+ * a lower bound: a Story's `## Spec` names the module it must also touch, its
200
+ * acceptance criteria name the caller that must be updated, and none of that
201
+ * reaches `changes[]`. The overlap guard exists to stop two Stories racing the
202
+ * same file, so trusting the declaration outright means the guard is blind to
203
+ * precisely the collisions nobody predicted.
204
+ *
205
+ * Evidence is only ever **added** — nothing here can shrink a declared
206
+ * footprint, so widening can withhold a Story for a beat but can never
207
+ * co-dispatch one the declared comparison would have caught.
208
+ *
209
+ * @param {StoryRecord} story
210
+ * @returns {Set<string>}
211
+ */
212
+ function storyEvidencePaths(story) {
213
+ const out = new Set();
214
+ for (const field of [story?.title, story?.body, story?.spec]) {
215
+ if (typeof field !== 'string' || field === '') continue;
216
+ for (const match of field.matchAll(PROSE_PATH_RE)) {
217
+ const trimmed = match[0].trim();
218
+ if (trimmed) out.add(trimmed);
219
+ }
220
+ }
221
+ return out;
222
+ }
223
+
224
+ /**
225
+ * A Story's footprint **widened from observable evidence** — the set the
226
+ * co-dispatch guard actually compares (Story #4875).
227
+ *
228
+ * `declared ∪ scraped-from-prose`. See {@link storyEvidencePaths} for why the
229
+ * declaration is treated as a lower bound rather than the answer.
230
+ *
231
+ * @param {StoryRecord} story
232
+ * @returns {Set<string>}
233
+ */
234
+ function storyWidenedFootprint(story) {
235
+ const out = storyFootprint(story);
236
+ for (const path of storyEvidencePaths(story)) out.add(path);
237
+ return out;
238
+ }
239
+
240
+ /**
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.
264
+ *
265
+ * Comparison runs over the **widened** footprint
266
+ * ({@link storyWidenedFootprint}), not the declaration: a declared `changes[]`
267
+ * is a lower bound on what a Story will touch, and two Stories whose real edits
268
+ * collide were being co-dispatched whenever the collision was not predicted
269
+ * (Story #4875).
270
+ *
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.
276
+ *
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).
190
280
  *
191
281
  * @param {StoryRecord} a
192
282
  * @param {StoryRecord} b
193
283
  * @returns {boolean}
194
284
  */
195
285
  export function storiesOverlap(a, b) {
196
- const fa = storyFootprint(a);
197
- if (fa.size === 0) return false;
198
- const fb = storyFootprint(b);
199
- if (fb.size === 0) return false;
286
+ const pair = widenedFootprintPair(a, b);
287
+ if (pair === null) return false;
288
+ const [fa, fb] = pair;
200
289
  for (const path of fa) {
201
- if (isGlobPath(path)) return true;
202
- if (fb.has(path)) return true;
290
+ if (isGlobPath(path) || fb.has(path)) return true;
203
291
  }
204
292
  for (const path of fb) {
205
293
  if (isGlobPath(path)) return true;
@@ -207,6 +295,42 @@ export function storiesOverlap(a, b) {
207
295
  return false;
208
296
  }
209
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
+
210
334
  /**
211
335
  * Select the set of Stories safe to dispatch on this beat.
212
336
  *
@@ -240,10 +364,12 @@ export function storiesOverlap(a, b) {
240
364
  * already occupying a slot (executing / closing / dispatched-not-yet-
241
365
  * labelled). When `slots <= 0`, the result is empty.
242
366
  * 5. **Overlap guard.** Greedily admit eligible Stories in ascending-id
243
- * order, skipping any whose file footprint overlaps an
244
- * already-admitted Story (`storiesOverlap`). A withheld Story stays
245
- * eligible and is naturally re-considered on the next beat once its
246
- * 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.
247
373
  *
248
374
  * The result is deterministic: eligible Stories are considered in
249
375
  * ascending-id order, so the same inputs always yield the same set.
@@ -261,22 +387,37 @@ export function storiesOverlap(a, b) {
261
387
  * step 1 above). `false` keeps a foreign dependency as a gate
262
388
  * (standalone / operator-DAG semantics); `true` prunes foreign edges so
263
389
  * the DAG stays closed over the scheduled set (Epic semantics).
264
- * @returns {StoryRecord[]} The dispatch set: a subset of `stories`,
265
- * 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.
266
404
  */
267
- export function selectReadySet({
405
+ export function planReadySet({
268
406
  stories,
269
407
  doneIds = [],
270
408
  inFlight = 0,
271
409
  globalCap,
272
410
  dropForeign = false,
411
+ inFlightRecords = [],
273
412
  } = {}) {
274
413
  const records = Array.isArray(stories) ? stories : [];
275
414
  const cap = Number.isInteger(globalCap) ? globalCap : 0;
276
415
  const inFlightCount =
277
416
  Number.isInteger(inFlight) && inFlight > 0 ? inFlight : 0;
278
417
  const slots = Math.max(0, cap - inFlightCount);
279
- if (slots <= 0 || records.length === 0) return [];
418
+ if (slots <= 0 || records.length === 0) {
419
+ return { selected: [], withheldByInFlight: [] };
420
+ }
280
421
 
281
422
  // Step 1 — adjacency keyed by id. The `dropForeign` policy decides whether
282
423
  // a dependency on an id outside the supplied set gates the dependent
@@ -309,13 +450,83 @@ export function selectReadySet({
309
450
  }
310
451
 
311
452
  // Steps 4 + 5 — greedily admit up to `slots`, skipping file-overlap
312
- // 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 }) {
313
489
  const selected = [];
490
+ const withheldByInFlight = [];
314
491
  for (const id of eligibleIds) {
315
492
  if (selected.length >= slots) break;
316
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
+ }
317
499
  if (selected.some((picked) => storiesOverlap(picked, rec))) continue;
318
500
  selected.push(rec);
319
501
  }
320
- 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;
321
532
  }
@@ -24,7 +24,7 @@
24
24
  *
25
25
  * Message contract — see lib/cpu-pool.js:
26
26
  * IN : { item: { abs: string, relPath: string, requireCoverage: boolean,
27
- * coverageEntry: object | null } }
27
+ * coverageAvailable?: boolean, coverageEntry: object | null } }
28
28
  * { exit: true }
29
29
  * OUT : { ok: true, result: {
30
30
  * relPath,
@@ -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
@@ -88,7 +89,7 @@ export function handleCombinedMiCrapWorkerMessage(msg, deps = {}) {
88
89
  },
89
90
  };
90
91
  }
91
- const { abs, relPath, requireCoverage } = item;
92
+ const { abs, relPath, requireCoverage, coverageAvailable = true } = item;
92
93
  const prepare = deps.prepare ?? prepareSourceForScoring;
93
94
  const analyze = deps.analyze ?? analyzeOnce;
94
95
 
@@ -150,7 +151,10 @@ export function handleCombinedMiCrapWorkerMessage(msg, deps = {}) {
150
151
  }
151
152
 
152
153
  const { rows, skippedMethodsNoCoverage, resolvedMethods, totalMethods } =
153
- finalizeMethodRows(rawCrapRows, { requireCoverage });
154
+ finalizeMethodRows(rawCrapRows, {
155
+ requireCoverage,
156
+ coverageAvailable,
157
+ });
154
158
  return reply({
155
159
  miScore,
156
160
  crapRows: rows,
@@ -160,13 +164,6 @@ export function handleCombinedMiCrapWorkerMessage(msg, deps = {}) {
160
164
  });
161
165
  }
162
166
 
163
- if (parentPort) {
164
- parentPort.on('message', (msg) => {
165
- const out = handleCombinedMiCrapWorkerMessage(msg);
166
- if (out.kind === 'exit') {
167
- parentPort.close();
168
- return;
169
- }
170
- parentPort.postMessage(out.message);
171
- });
172
- }
167
+ serveWorkerMessages(parentPort, (msg) =>
168
+ handleCombinedMiCrapWorkerMessage(msg),
169
+ );
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * Message contract — see lib/cpu-pool.js:
13
13
  * IN : { item: { abs: string, relPath: string, requireCoverage: boolean,
14
- * coverageEntry: object | null } }
14
+ * coverageAvailable?: boolean, coverageEntry: object | null } }
15
15
  * { exit: true }
16
16
  * OUT : { ok: true, result: {
17
17
  * relPath,
@@ -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
@@ -80,7 +81,7 @@ export function handleCrapWorkerMessage(msg, _coverage, deps = {}) {
80
81
  },
81
82
  };
82
83
  }
83
- const { abs, relPath, requireCoverage } = item;
84
+ const { abs, relPath, requireCoverage, coverageAvailable = true } = item;
84
85
 
85
86
  // Coverage entry is pre-resolved on the host and attached to the item.
86
87
  // `item.coverageEntry` may be explicitly `null` when the file has no
@@ -143,7 +144,10 @@ export function handleCrapWorkerMessage(msg, _coverage, deps = {}) {
143
144
  );
144
145
  }
145
146
 
146
- const finalized = finalizeMethodRows(methodRows, { requireCoverage });
147
+ const finalized = finalizeMethodRows(methodRows, {
148
+ requireCoverage,
149
+ coverageAvailable,
150
+ });
147
151
  return {
148
152
  kind: 'reply',
149
153
  message: {
@@ -158,13 +162,4 @@ export function handleCrapWorkerMessage(msg, _coverage, deps = {}) {
158
162
  };
159
163
  }
160
164
 
161
- if (parentPort) {
162
- parentPort.on('message', (msg) => {
163
- const out = handleCrapWorkerMessage(msg, null);
164
- if (out.kind === 'exit') {
165
- parentPort.close();
166
- return;
167
- }
168
- parentPort.postMessage(out.message);
169
- });
170
- }
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
+ }