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
@@ -5,7 +5,7 @@
5
5
  * `/deliver` story-list path.
6
6
  *
7
7
  * Thin **adapter** over the path-agnostic ready-set scheduling core
8
- * (`lib/wave-runner/ready-set.js#selectReadySet`). It emits the set of
8
+ * (`lib/wave-runner/ready-set.js#planReadySet`). It emits the set of
9
9
  * Stories safe to dispatch **on this beat** — a Story becomes dispatchable
10
10
  * the instant its own dependencies are done, under the same global
11
11
  * concurrency cap and the same file-overlap co-dispatch guard
@@ -15,7 +15,7 @@
15
15
  * The previous static wave-batch plan (group N must fully drain before
16
16
  * group N+1 opens, via `Graph.js#assignLayers`) is gone. The scheduling
17
17
  * kernel — adjacency derivation, the done-predicate classifier, the
18
- * eligibility rule, and the overlap guard — lives once in `selectReadySet`;
18
+ * eligibility rule, and the overlap guard — lives once in `planReadySet`;
19
19
  * this file only gathers input, resolves the cap, and renders the envelope.
20
20
  *
21
21
  * **Two modes, one kernel.**
@@ -54,9 +54,22 @@
54
54
  * concurrencyCap: number,
55
55
  * inFlight: number,
56
56
  * cycleError: string | null,
57
- * wedged: { reason, stories: [{ id, unmetBlockers }] } | null
57
+ * wedged: { reason, stories: [{ id, unmetBlockers }] } | null,
58
+ * inFlightReservation: { available, withheld: [{ id, blockedBy, reason }], note }
58
59
  * }
59
60
  *
61
+ * `inFlightReservation` reports the cross-beat half of the co-dispatch guard
62
+ * (Story #4875 widened the footprint; Story #4950 made it reserve). Under
63
+ * `--probe-live` the in-flight Stories' own records are handed to the kernel,
64
+ * so a candidate sharing a CONCRETE path with a Story dispatched on an EARLIER
65
+ * beat is withheld and named here with its blocker and a `reason`
66
+ * (`in-flight-earlier-beat` or `foreign-lease`). A glob / UNKNOWN footprint
67
+ * reserves nothing across beats — it would withhold the whole run for one
68
+ * blocker's entire implementation window — while still serializing its own
69
+ * beat (Story #4960). Flag mode carries a count and no records, so it reports
70
+ * `available: false` rather than an empty — and therefore indistinguishable —
71
+ * result.
72
+ *
60
73
  * Probe mode adds fields the caller can no longer compute for itself:
61
74
  * `done: number[]` (the resolved done set, in-set ∪ satisfied foreign
62
75
  * blockers), `epilogueDue: boolean` (true exactly when every listed Story
@@ -75,7 +88,9 @@
75
88
  * `/deliver` uses — `resolveConfig` + `getRunners` reading
76
89
  * `delivery.deliverRunner.concurrencyCap` (default 3) — so a
77
90
  * `.agentrc.local.json` override is honored. A `--concurrency <n>` CLI flag
78
- * overrides the config-resolved value for that run only. This shares one
91
+ * overrides the config-resolved value for that run only, and the envelope's
92
+ * `capPrecedence` names which source won so the override is never silent
93
+ * (Story #4875). This shares one
79
94
  * deterministic config source (`delivery.deliverRunner.concurrencyCap`) and
80
95
  * one scheduling kernel with every `/deliver` multi-Story invocation.
81
96
  *
@@ -102,12 +117,13 @@ import { Logger } from './lib/Logger.js';
102
117
  import { AGENT_LABELS } from './lib/label-constants.js';
103
118
  import { parseIds } from './lib/orchestration/resolve-stories.js';
104
119
  import { buildStoryAdjacency } from './lib/story-adjacency.js';
120
+ import { expandIdList } from './lib/util/parse-id-list.js';
105
121
  import {
106
122
  createProbeContext,
107
123
  probeLiveState,
108
124
  validateProbeFlags,
109
125
  } from './lib/wave-runner/live-probe.js';
110
- import { selectReadySet } from './lib/wave-runner/ready-set.js';
126
+ import { planReadySet } from './lib/wave-runner/ready-set.js';
111
127
 
112
128
  /**
113
129
  * Exit code for a wedged run — deliberately distinct from the cycle exit (2)
@@ -133,7 +149,7 @@ const HELP = `Usage:
133
149
  Continuous ready-set planner for standalone Story delivery. Emits the set of
134
150
  Stories safe to dispatch on this beat — a Story is dispatchable the instant
135
151
  its own dependencies are done — plus the resolved per-beat concurrency cap
136
- and the same file-overlap guard as selectReadySet.
152
+ and the same file-overlap guard as planReadySet.
137
153
 
138
154
  Two modes:
139
155
  --probe-live Resolve the graph and derive done / in-flight from LIVE state
@@ -151,9 +167,10 @@ Each entry must include:
151
167
  dependsOn - Array of Story IDs that must complete before this Story runs
152
168
 
153
169
  Options:
154
- --stories <csv> Story ids to deliver (probe mode). The graph, the done
155
- set, and the in-flight count are resolved from live
156
- state no --done / --in-flight bookkeeping.
170
+ --stories <csv> Story ids to deliver (probe mode). Singles or inclusive
171
+ A-B ranges (101,104-107). The graph, the done set, and
172
+ the in-flight count are resolved from live state — no
173
+ --done / --in-flight bookkeeping.
157
174
  --probe-live Enable probe mode. Requires --stories.
158
175
  --dispatched <csv> Probe mode only. Ids you have SPAWNED this run. Unioned
159
176
  into the live-derived in-flight set, then filtered by
@@ -167,7 +184,10 @@ Options:
167
184
  --concurrency <n> Override the per-beat concurrency cap for this run only.
168
185
  Must be a positive integer. When omitted, the cap is
169
186
  resolved from delivery.deliverRunner.concurrencyCap in
170
- .agentrc.json / .agentrc.local.json (default 3).
187
+ .agentrc.json / .agentrc.local.json (default 3). The flag
188
+ WINS over the configured value, and the envelope's
189
+ capPrecedence records that it did — including when the
190
+ request exceeds the configured cap.
171
191
  --done <csv> Comma-separated Story IDs already completed this run.
172
192
  Their dependents become eligible; they are never
173
193
  re-dispatched. Defaults to empty.
@@ -181,11 +201,30 @@ Output envelope:
181
201
  "ready": [101],
182
202
  "totalStories": 2,
183
203
  "concurrencyCap": 3,
204
+ "capPrecedence": {
205
+ "cap": 3,
206
+ "source": "config",
207
+ "configuredCap": 3,
208
+ "requestedCap": null,
209
+ "exceedsConfigured": false,
210
+ "note": "..."
211
+ },
184
212
  "inFlight": 0,
185
213
  "cycleError": null,
186
- "wedged": null
214
+ "wedged": null,
215
+ "inFlightReservation": {
216
+ "available": true,
217
+ "withheld": [{ "id": 4951, "blockedBy": 4949 }],
218
+ "note": "..."
219
+ }
187
220
  }
188
221
 
222
+ inFlightReservation names each Story withheld this beat because its file
223
+ footprint overlaps one still IN FLIGHT from an earlier beat, together with the
224
+ blocking id — so an unfilled slot is explained rather than mysterious. It needs
225
+ the in-flight Stories' footprints, which only --probe-live has: under --dag the
226
+ report is { available: false } and selection de-conflicts within the beat only.
227
+
189
228
  Exit codes:
190
229
  0 - Success, ready set emitted
191
230
  1 - Invalid input (missing/malformed DAG, invalid --concurrency/--in-flight/--done)
@@ -214,15 +253,128 @@ function inputErrorResult(message, concurrencyCap = null, inFlightValue = 0) {
214
253
  ready: [],
215
254
  totalStories: 0,
216
255
  concurrencyCap,
256
+ capPrecedence: null,
217
257
  inFlight: inFlightValue,
218
258
  cycleError: null,
219
259
  wedged: null,
260
+ inFlightReservation: null,
220
261
  inputError: message,
221
262
  },
222
263
  exitCode: 1,
223
264
  };
224
265
  }
225
266
 
267
+ /**
268
+ * Why a reservation withheld a Story. Machine-readable companion to the
269
+ * operator-facing `note`, so a consumer never has to parse prose to tell an
270
+ * earlier-beat blocker from a foreign-lease one (Story #4960).
271
+ */
272
+ const RESERVATION_REASONS = Object.freeze({
273
+ EARLIER_BEAT: 'in-flight-earlier-beat',
274
+ FOREIGN_LEASE: 'foreign-lease',
275
+ });
276
+
277
+ /**
278
+ * Describe this beat's in-flight footprint reservation for the envelope
279
+ * (Story #4950).
280
+ *
281
+ * The reservation needs the in-flight Stories' **records** — their footprints
282
+ * — not just how many there are, so its availability is a property of the
283
+ * mode rather than of the run:
284
+ *
285
+ * - **Probe mode** hands over the records `live-probe.js` already fetched,
286
+ * so `inFlightRecords` is an array (possibly empty) and reservation is
287
+ * `available: true`.
288
+ * - **Flag mode** (`--dag` + `--in-flight <n>`) supplies a graph and a
289
+ * count; no node carries a label, so nothing there can even classify as
290
+ * in-flight. There is no footprint to reserve against, and selection is
291
+ * unchanged from before #4950. Saying so explicitly is the point: a
292
+ * silently-absent guard reads exactly like a guard that found nothing.
293
+ *
294
+ * A reservation is held either by a Story **this run** dispatched on an
295
+ * earlier beat or by one **another operator's lease** holds — `live-probe.js`
296
+ * folds both into the in-flight set, and they reserve identically but read
297
+ * very differently to an operator. Reporting a foreign-held peer as "still in
298
+ * flight from an earlier beat" is simply false: this run never dispatched it
299
+ * and no later beat of this run will clear it. `foreignHeldIds` splits the two
300
+ * so each carries its own reason (Story #4960).
301
+ *
302
+ * @param {object[]|null|undefined} inFlightRecords
303
+ * @param {Array<{id: number, blockedBy: number}>} withheld
304
+ * @param {Iterable<number>} [foreignHeldIds] Ids held by a foreign lease.
305
+ * @returns {{ available: boolean, withheld: Array<{id: number, blockedBy: number, reason: string}>, note: string|null }}
306
+ */
307
+ export function buildReservationReport(
308
+ inFlightRecords,
309
+ withheld,
310
+ foreignHeldIds = [],
311
+ ) {
312
+ if (!Array.isArray(inFlightRecords)) {
313
+ return {
314
+ available: false,
315
+ withheld: [],
316
+ note:
317
+ 'In-flight footprint reservation is UNAVAILABLE this beat: flag mode ' +
318
+ 'supplies a dependency graph and an --in-flight count, never the ' +
319
+ 'in-flight Stories themselves, so there are no footprints to reserve ' +
320
+ 'against. Selection is unchanged (same-beat de-confliction only). ' +
321
+ 'Use --probe-live to reserve in-flight footprints.',
322
+ };
323
+ }
324
+ if (withheld.length === 0) {
325
+ return { available: true, withheld: [], note: null };
326
+ }
327
+ const foreign = new Set(foreignHeldIds);
328
+ const classified = withheld.map((w) => ({
329
+ ...w,
330
+ reason: foreign.has(w.blockedBy)
331
+ ? RESERVATION_REASONS.FOREIGN_LEASE
332
+ : RESERVATION_REASONS.EARLIER_BEAT,
333
+ }));
334
+ return {
335
+ available: true,
336
+ withheld: classified,
337
+ note: reservationNote(classified),
338
+ };
339
+ }
340
+
341
+ /**
342
+ * Render the operator-facing reservation note, one sentence per reason class
343
+ * present. Neither class is a failure or a wedge, but they clear by different
344
+ * events, so each names its own.
345
+ *
346
+ * @param {Array<{id: number, blockedBy: number, reason: string}>} withheld
347
+ * @returns {string}
348
+ */
349
+ function reservationNote(withheld) {
350
+ const detail = (entries) =>
351
+ entries.map((w) => `#${w.id} ← #${w.blockedBy}`).join('; ');
352
+ const byBeat = withheld.filter(
353
+ (w) => w.reason === RESERVATION_REASONS.EARLIER_BEAT,
354
+ );
355
+ const byLease = withheld.filter(
356
+ (w) => w.reason === RESERVATION_REASONS.FOREIGN_LEASE,
357
+ );
358
+ const parts = [];
359
+ if (byBeat.length > 0) {
360
+ parts.push(
361
+ `${byBeat.length} Story(ies) withheld because their file footprint ` +
362
+ `overlaps a Story still in flight from an earlier beat — ${detail(byBeat)}. ` +
363
+ `Each re-admits automatically on a later beat, once the Story ` +
364
+ `reserving its files leaves the in-flight set.`,
365
+ );
366
+ }
367
+ if (byLease.length > 0) {
368
+ parts.push(
369
+ `${byLease.length} Story(ies) withheld because their file footprint ` +
370
+ `overlaps a Story another operator's lease holds — ${detail(byLease)}. ` +
371
+ `No beat of THIS run clears that: the peer is the holder's work, and ` +
372
+ `each re-admits once their lease clears (see foreignHeldReason).`,
373
+ );
374
+ }
375
+ return `${parts.join(' ')} Neither is a wedge and neither is a failure.`;
376
+ }
377
+
226
378
  /**
227
379
  * Parse and validate the raw DAG input array.
228
380
  *
@@ -294,33 +446,22 @@ export function parseDag(raw) {
294
446
  }
295
447
 
296
448
  /**
297
- * Parse a comma-separated list of Story IDs into a deduped set of positive
298
- * integers. Empty / absent input yields an empty set. Rejects any token that
299
- * is not a positive integer so a typo never silently drops a dependency gate
300
- * (`--done`) or a held dispatch slot (`--dispatched`).
449
+ * Parse a comma-separated list of Story IDs singles or `A-B` dash ranges —
450
+ * into a deduped set of positive integers. Empty / absent input yields an
451
+ * empty set. Rejects any token that is not a positive integer or a valid
452
+ * range, so a typo never silently drops a dependency gate (`--done`) or a
453
+ * held dispatch slot (`--dispatched`).
454
+ *
455
+ * Ranges are accepted here for the same reason `--stories` accepts them: an
456
+ * operator delivering `4922-4926` writes the dispatched set back the same way.
301
457
  *
302
458
  * @param {string|undefined} raw
303
459
  * @param {string} flag Flag name, for the error message.
304
460
  * @returns {{ ids: Set<number>|null, error: string|null }}
305
461
  */
306
462
  export function parseIdCsv(raw, flag) {
307
- if (raw == null || raw === '') {
308
- return { ids: new Set(), error: null };
309
- }
310
- const ids = new Set();
311
- for (const token of String(raw).split(',')) {
312
- const trimmed = token.trim();
313
- if (trimmed === '') continue;
314
- const num = Number(trimmed);
315
- if (!Number.isInteger(num) || num <= 0) {
316
- return {
317
- ids: null,
318
- error: `${flag} must be a comma-separated list of positive integers, got "${trimmed}"`,
319
- };
320
- }
321
- ids.add(num);
322
- }
323
- return { ids, error: null };
463
+ const { ids, error } = expandIdList(raw, { flag });
464
+ return error ? { ids: null, error } : { ids: new Set(ids), error: null };
324
465
  }
325
466
 
326
467
  /**
@@ -394,13 +535,69 @@ export function parseConcurrencyOverride(raw) {
394
535
  * `--concurrency`; wins over config.
395
536
  * @returns {number} The resolved positive-integer concurrency cap.
396
537
  */
397
- export function resolveConcurrencyCap({ cwd, config, override } = {}) {
398
- if (override != null) {
399
- return override;
400
- }
538
+ export function resolveConcurrencyCap(opts = {}) {
539
+ return resolveCapPrecedence(opts).cap;
540
+ }
541
+
542
+ /**
543
+ * Resolve the per-beat cap **and the precedence that produced it** (Story
544
+ * #4875).
545
+ *
546
+ * `--concurrency` wins over `delivery.deliverRunner.concurrencyCap`, and that
547
+ * is the intended contract — a flag an operator typed for one run should not be
548
+ * outranked by a checked-in default. What was wrong is that it won *silently*:
549
+ * the envelope reported a single `concurrencyCap` number with no record of
550
+ * which source set it, so a run at 8 when the project configured 3 was
551
+ * indistinguishable from a project configured at 8. A reader could not tell an
552
+ * override from a default, and an override that ran the repo above its own
553
+ * configured ceiling left no trace at all.
554
+ *
555
+ * So the flag still wins, but never quietly: the source is named, the
556
+ * configured value is carried alongside the requested one, and a request that
557
+ * exceeds the configured cap is called out as such. Reporting rather than
558
+ * refusing is deliberate — the configured cap is a project default, not a
559
+ * safety limit, and refusing a deliberate operator escalation would trade a
560
+ * silent override for a silent stall.
561
+ *
562
+ * @param {object} [opts]
563
+ * @param {string} [opts.cwd] Repo root for config resolution.
564
+ * @param {object} [opts.config] Pre-resolved config (test injection).
565
+ * @param {number} [opts.override] Validated positive integer from
566
+ * `--concurrency`.
567
+ * @returns {{
568
+ * cap: number,
569
+ * source: 'flag'|'config',
570
+ * configuredCap: number,
571
+ * requestedCap: number|null,
572
+ * exceedsConfigured: boolean,
573
+ * note: string,
574
+ * }}
575
+ */
576
+ export function resolveCapPrecedence({ cwd, config, override } = {}) {
401
577
  const resolved = config ?? resolveConfig({ cwd });
402
578
  const { deliverRunner } = getRunners(resolved);
403
- return deliverRunner.concurrencyCap;
579
+ const configuredCap = deliverRunner.concurrencyCap;
580
+ if (override == null) {
581
+ return {
582
+ cap: configuredCap,
583
+ source: 'config',
584
+ configuredCap,
585
+ requestedCap: null,
586
+ exceedsConfigured: false,
587
+ note: `cap ${configuredCap} from delivery.deliverRunner.concurrencyCap (no --concurrency given)`,
588
+ };
589
+ }
590
+ const exceedsConfigured = override > configuredCap;
591
+ return {
592
+ cap: override,
593
+ source: 'flag',
594
+ configuredCap,
595
+ requestedCap: override,
596
+ exceedsConfigured,
597
+ note: exceedsConfigured
598
+ ? `cap ${override} from --concurrency, which OVERRIDES and EXCEEDS the configured delivery.deliverRunner.concurrencyCap ${configuredCap} — this run is deliberately above the project default`
599
+ : `cap ${override} from --concurrency, which overrides the configured delivery.deliverRunner.concurrencyCap ${configuredCap}`,
600
+ };
404
601
  }
405
602
 
406
603
  /**
@@ -410,7 +607,7 @@ export function resolveConcurrencyCap({ cwd, config, override } = {}) {
410
607
  * understands (`{ id, dependsOn }`), tags any node already in the done set
411
608
  * as `agent::done` so the core's classifier excludes it from the dispatch
412
609
  * set **and** counts it as a satisfied dependency, then delegates the
413
- * scheduling decision to `selectReadySet`. A cyclic operator DAG is a
610
+ * scheduling decision to `planReadySet`. A cyclic operator DAG is a
414
611
  * planning error (the core would silently never schedule the cycle), so we
415
612
  * detect it up front via the shared `detectCycle` kernel and short-circuit
416
613
  * with a `cycleError` and exit code 2.
@@ -418,8 +615,18 @@ export function resolveConcurrencyCap({ cwd, config, override } = {}) {
418
615
  * @param {Array<{id: number, dependsOn: number[]}>} nodes
419
616
  * @param {object} args
420
617
  * @param {number} args.concurrencyCap Resolved per-beat concurrency cap.
618
+ * @param {object|null} [args.capPrecedence] The {@link resolveCapPrecedence}
619
+ * record explaining which source set the cap.
421
620
  * @param {Set<number>} [args.doneIds] Story IDs already completed this run.
422
621
  * @param {number} [args.inFlight] Stories already occupying a slot.
622
+ * @param {object[]|null} [args.inFlightRecords] Records for the Stories
623
+ * already in flight, so the kernel reserves their footprints instead of
624
+ * merely counting them (Story #4950). `null` (flag mode) means reservation
625
+ * is structurally unavailable — see {@link buildReservationReport}.
626
+ * @param {number[]} [args.foreignHeldIds] Ids among `inFlightRecords` that a
627
+ * **foreign operator's lease** holds rather than this run's own earlier
628
+ * beat, so a withholding against one is reported for what it is
629
+ * (Story #4960). Flag mode has no lease view and passes none.
423
630
  * @returns {{
424
631
  * envelope: {
425
632
  * kind: 'stories-ready-set',
@@ -434,7 +641,14 @@ export function resolveConcurrencyCap({ cwd, config, override } = {}) {
434
641
  */
435
642
  export function buildReadySetEnvelope(
436
643
  nodes,
437
- { concurrencyCap, doneIds = new Set(), inFlight = 0 },
644
+ {
645
+ concurrencyCap,
646
+ capPrecedence = null,
647
+ doneIds = new Set(),
648
+ inFlight = 0,
649
+ inFlightRecords = null,
650
+ foreignHeldIds = [],
651
+ },
438
652
  ) {
439
653
  const totalStories = nodes.length;
440
654
 
@@ -443,9 +657,17 @@ export function buildReadySetEnvelope(
443
657
  ready: [],
444
658
  totalStories,
445
659
  concurrencyCap,
660
+ // Which source set `concurrencyCap`, and whether it outranks the project's
661
+ // configured value (Story #4875). Never omitted on a resolved beat: a
662
+ // missing precedence record is what made a silent override possible.
663
+ capPrecedence,
446
664
  inFlight,
447
665
  cycleError: null,
448
666
  wedged: null,
667
+ // Whether this beat could reserve the in-flight Stories' footprints, and
668
+ // which Stories a reservation withheld (Story #4950). Never omitted on a
669
+ // resolved beat: an absent report reads exactly like an empty one.
670
+ inFlightReservation: buildReservationReport(inFlightRecords, []),
449
671
  };
450
672
 
451
673
  if (totalStories === 0) {
@@ -455,7 +677,7 @@ export function buildReadySetEnvelope(
455
677
  // Cycle detection before scheduling — a cycle is a planning error the
456
678
  // operator must fix. dropForeign:false preserves the operator-DAG contract
457
679
  // (a dependency on an id outside the supplied set is honored, not pruned),
458
- // matching the same builder seam selectReadySet uses internally.
680
+ // matching the same builder seam planReadySet uses internally.
459
681
  const adjacency = buildStoryAdjacency(nodes, { dropForeign: false });
460
682
  const cycle = detectCycle(adjacency);
461
683
  if (cycle) {
@@ -484,15 +706,30 @@ export function buildReadySetEnvelope(
484
706
  labels: doneIds.has(node.id) ? [AGENT_LABELS.DONE] : (node.labels ?? []),
485
707
  };
486
708
  if (node.files !== undefined) rec.files = node.files;
709
+ // Probe-mode nodes carry the Story body so the overlap guard can widen a
710
+ // declared footprint from the paths the Story's own text names (Story
711
+ // #4875). Flag-mode nodes carry none — `parseDag` accepts no body — so
712
+ // this is inert there and the legacy contract is unchanged.
713
+ if (typeof node.body === 'string') rec.body = node.body;
487
714
  return rec;
488
715
  });
489
716
 
490
- const ready = selectReadySet({
717
+ const { selected, withheldByInFlight } = planReadySet({
491
718
  stories: records,
492
719
  doneIds,
493
720
  inFlight,
494
721
  globalCap: concurrencyCap,
495
- }).map((rec) => rec.id);
722
+ // Flag mode has no in-flight records at all; `?? []` keeps the kernel's
723
+ // contract (an array) while `base.inFlightReservation` reports that the
724
+ // reservation itself was unavailable rather than merely empty.
725
+ inFlightRecords: inFlightRecords ?? [],
726
+ });
727
+ const ready = selected.map((rec) => rec.id);
728
+ const reservation = buildReservationReport(
729
+ inFlightRecords,
730
+ withheldByInFlight,
731
+ foreignHeldIds,
732
+ );
496
733
 
497
734
  // Wedge detection (Story #4540). `ready: []` is normal while work is in
498
735
  // flight — the loop is simply waiting. But ready-empty AND nothing in
@@ -508,12 +745,25 @@ export function buildReadySetEnvelope(
508
745
  const wedge = detectWedge({ nodes, doneIds, ready, inFlight });
509
746
  if (wedge) {
510
747
  return {
511
- envelope: { ...base, ready, wedged: wedge },
748
+ envelope: {
749
+ ...base,
750
+ ready,
751
+ wedged: wedge,
752
+ inFlightReservation: reservation,
753
+ },
512
754
  exitCode: WEDGED_EXIT_CODE,
513
755
  };
514
756
  }
515
757
 
516
- return { envelope: { ...base, ready, wedged: null }, exitCode: 0 };
758
+ return {
759
+ envelope: {
760
+ ...base,
761
+ ready,
762
+ wedged: null,
763
+ inFlightReservation: reservation,
764
+ },
765
+ exitCode: 0,
766
+ };
517
767
  }
518
768
 
519
769
  /**
@@ -600,7 +850,8 @@ export function runStoriesWaveTick({
600
850
  return inputErrorResult(doneError, null, inFlightValue);
601
851
  }
602
852
 
603
- const concurrencyCap = resolveConcurrencyCap({ cwd, config, override });
853
+ const capPrecedence = resolveCapPrecedence({ cwd, config, override });
854
+ const concurrencyCap = capPrecedence.cap;
604
855
 
605
856
  let rawJson;
606
857
 
@@ -642,6 +893,7 @@ export function runStoriesWaveTick({
642
893
 
643
894
  return buildReadySetEnvelope(nodes, {
644
895
  concurrencyCap,
896
+ capPrecedence,
645
897
  doneIds,
646
898
  inFlight: inFlightValue,
647
899
  });
@@ -692,7 +944,7 @@ export async function runProbedStoriesWaveTick({
692
944
 
693
945
  let ids;
694
946
  try {
695
- ids = parseIds(stories);
947
+ ids = parseIds(stories, '--stories');
696
948
  } catch (err) {
697
949
  return inputErrorResult(err.message);
698
950
  }
@@ -705,7 +957,8 @@ export async function runProbedStoriesWaveTick({
705
957
  return inputErrorResult(dispatchedError);
706
958
  }
707
959
 
708
- const concurrencyCap = resolveConcurrencyCap({ cwd, config, override });
960
+ const capPrecedence = resolveCapPrecedence({ cwd, config, override });
961
+ const concurrencyCap = capPrecedence.cap;
709
962
 
710
963
  let probed;
711
964
  try {
@@ -735,11 +988,19 @@ export async function runProbedStoriesWaveTick({
735
988
  inFlight,
736
989
  blockedIds = [],
737
990
  foreignHeld = [],
991
+ inFlightRecords = [],
738
992
  } = probed;
739
993
  const { envelope, exitCode } = buildReadySetEnvelope(nodes, {
740
994
  concurrencyCap,
995
+ capPrecedence,
741
996
  doneIds,
742
997
  inFlight,
998
+ // Probe mode is the only mode that HAS the in-flight Stories' records, so
999
+ // it is the only mode that can reserve their footprints (Story #4950).
1000
+ inFlightRecords,
1001
+ // ...and the only mode that can tell a foreign lease-holder apart from
1002
+ // this run's own earlier-beat dispatch (Story #4960).
1003
+ foreignHeldIds: foreignHeld.map((h) => h.id),
743
1004
  });
744
1005
 
745
1006
  const done = [...doneIds].sort((a, b) => a - b);