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
@@ -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
@@ -104,12 +117,13 @@ import { Logger } from './lib/Logger.js';
104
117
  import { AGENT_LABELS } from './lib/label-constants.js';
105
118
  import { parseIds } from './lib/orchestration/resolve-stories.js';
106
119
  import { buildStoryAdjacency } from './lib/story-adjacency.js';
120
+ import { expandIdList } from './lib/util/parse-id-list.js';
107
121
  import {
108
122
  createProbeContext,
109
123
  probeLiveState,
110
124
  validateProbeFlags,
111
125
  } from './lib/wave-runner/live-probe.js';
112
- import { selectReadySet } from './lib/wave-runner/ready-set.js';
126
+ import { planReadySet } from './lib/wave-runner/ready-set.js';
113
127
 
114
128
  /**
115
129
  * Exit code for a wedged run — deliberately distinct from the cycle exit (2)
@@ -135,7 +149,7 @@ const HELP = `Usage:
135
149
  Continuous ready-set planner for standalone Story delivery. Emits the set of
136
150
  Stories safe to dispatch on this beat — a Story is dispatchable the instant
137
151
  its own dependencies are done — plus the resolved per-beat concurrency cap
138
- and the same file-overlap guard as selectReadySet.
152
+ and the same file-overlap guard as planReadySet.
139
153
 
140
154
  Two modes:
141
155
  --probe-live Resolve the graph and derive done / in-flight from LIVE state
@@ -153,9 +167,10 @@ Each entry must include:
153
167
  dependsOn - Array of Story IDs that must complete before this Story runs
154
168
 
155
169
  Options:
156
- --stories <csv> Story ids to deliver (probe mode). The graph, the done
157
- set, and the in-flight count are resolved from live
158
- 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.
159
174
  --probe-live Enable probe mode. Requires --stories.
160
175
  --dispatched <csv> Probe mode only. Ids you have SPAWNED this run. Unioned
161
176
  into the live-derived in-flight set, then filtered by
@@ -196,9 +211,20 @@ Output envelope:
196
211
  },
197
212
  "inFlight": 0,
198
213
  "cycleError": null,
199
- "wedged": null
214
+ "wedged": null,
215
+ "inFlightReservation": {
216
+ "available": true,
217
+ "withheld": [{ "id": 4951, "blockedBy": 4949 }],
218
+ "note": "..."
219
+ }
200
220
  }
201
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
+
202
228
  Exit codes:
203
229
  0 - Success, ready set emitted
204
230
  1 - Invalid input (missing/malformed DAG, invalid --concurrency/--in-flight/--done)
@@ -231,12 +257,124 @@ function inputErrorResult(message, concurrencyCap = null, inFlightValue = 0) {
231
257
  inFlight: inFlightValue,
232
258
  cycleError: null,
233
259
  wedged: null,
260
+ inFlightReservation: null,
234
261
  inputError: message,
235
262
  },
236
263
  exitCode: 1,
237
264
  };
238
265
  }
239
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
+
240
378
  /**
241
379
  * Parse and validate the raw DAG input array.
242
380
  *
@@ -308,33 +446,22 @@ export function parseDag(raw) {
308
446
  }
309
447
 
310
448
  /**
311
- * Parse a comma-separated list of Story IDs into a deduped set of positive
312
- * integers. Empty / absent input yields an empty set. Rejects any token that
313
- * is not a positive integer so a typo never silently drops a dependency gate
314
- * (`--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.
315
457
  *
316
458
  * @param {string|undefined} raw
317
459
  * @param {string} flag Flag name, for the error message.
318
460
  * @returns {{ ids: Set<number>|null, error: string|null }}
319
461
  */
320
462
  export function parseIdCsv(raw, flag) {
321
- if (raw == null || raw === '') {
322
- return { ids: new Set(), error: null };
323
- }
324
- const ids = new Set();
325
- for (const token of String(raw).split(',')) {
326
- const trimmed = token.trim();
327
- if (trimmed === '') continue;
328
- const num = Number(trimmed);
329
- if (!Number.isInteger(num) || num <= 0) {
330
- return {
331
- ids: null,
332
- error: `${flag} must be a comma-separated list of positive integers, got "${trimmed}"`,
333
- };
334
- }
335
- ids.add(num);
336
- }
337
- return { ids, error: null };
463
+ const { ids, error } = expandIdList(raw, { flag });
464
+ return error ? { ids: null, error } : { ids: new Set(ids), error: null };
338
465
  }
339
466
 
340
467
  /**
@@ -480,7 +607,7 @@ export function resolveCapPrecedence({ cwd, config, override } = {}) {
480
607
  * understands (`{ id, dependsOn }`), tags any node already in the done set
481
608
  * as `agent::done` so the core's classifier excludes it from the dispatch
482
609
  * set **and** counts it as a satisfied dependency, then delegates the
483
- * scheduling decision to `selectReadySet`. A cyclic operator DAG is a
610
+ * scheduling decision to `planReadySet`. A cyclic operator DAG is a
484
611
  * planning error (the core would silently never schedule the cycle), so we
485
612
  * detect it up front via the shared `detectCycle` kernel and short-circuit
486
613
  * with a `cycleError` and exit code 2.
@@ -492,6 +619,14 @@ export function resolveCapPrecedence({ cwd, config, override } = {}) {
492
619
  * record explaining which source set the cap.
493
620
  * @param {Set<number>} [args.doneIds] Story IDs already completed this run.
494
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.
495
630
  * @returns {{
496
631
  * envelope: {
497
632
  * kind: 'stories-ready-set',
@@ -506,7 +641,14 @@ export function resolveCapPrecedence({ cwd, config, override } = {}) {
506
641
  */
507
642
  export function buildReadySetEnvelope(
508
643
  nodes,
509
- { concurrencyCap, capPrecedence = null, doneIds = new Set(), inFlight = 0 },
644
+ {
645
+ concurrencyCap,
646
+ capPrecedence = null,
647
+ doneIds = new Set(),
648
+ inFlight = 0,
649
+ inFlightRecords = null,
650
+ foreignHeldIds = [],
651
+ },
510
652
  ) {
511
653
  const totalStories = nodes.length;
512
654
 
@@ -522,6 +664,10 @@ export function buildReadySetEnvelope(
522
664
  inFlight,
523
665
  cycleError: null,
524
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, []),
525
671
  };
526
672
 
527
673
  if (totalStories === 0) {
@@ -531,7 +677,7 @@ export function buildReadySetEnvelope(
531
677
  // Cycle detection before scheduling — a cycle is a planning error the
532
678
  // operator must fix. dropForeign:false preserves the operator-DAG contract
533
679
  // (a dependency on an id outside the supplied set is honored, not pruned),
534
- // matching the same builder seam selectReadySet uses internally.
680
+ // matching the same builder seam planReadySet uses internally.
535
681
  const adjacency = buildStoryAdjacency(nodes, { dropForeign: false });
536
682
  const cycle = detectCycle(adjacency);
537
683
  if (cycle) {
@@ -568,12 +714,22 @@ export function buildReadySetEnvelope(
568
714
  return rec;
569
715
  });
570
716
 
571
- const ready = selectReadySet({
717
+ const { selected, withheldByInFlight } = planReadySet({
572
718
  stories: records,
573
719
  doneIds,
574
720
  inFlight,
575
721
  globalCap: concurrencyCap,
576
- }).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
+ );
577
733
 
578
734
  // Wedge detection (Story #4540). `ready: []` is normal while work is in
579
735
  // flight — the loop is simply waiting. But ready-empty AND nothing in
@@ -589,12 +745,25 @@ export function buildReadySetEnvelope(
589
745
  const wedge = detectWedge({ nodes, doneIds, ready, inFlight });
590
746
  if (wedge) {
591
747
  return {
592
- envelope: { ...base, ready, wedged: wedge },
748
+ envelope: {
749
+ ...base,
750
+ ready,
751
+ wedged: wedge,
752
+ inFlightReservation: reservation,
753
+ },
593
754
  exitCode: WEDGED_EXIT_CODE,
594
755
  };
595
756
  }
596
757
 
597
- 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
+ };
598
767
  }
599
768
 
600
769
  /**
@@ -775,7 +944,7 @@ export async function runProbedStoriesWaveTick({
775
944
 
776
945
  let ids;
777
946
  try {
778
- ids = parseIds(stories);
947
+ ids = parseIds(stories, '--stories');
779
948
  } catch (err) {
780
949
  return inputErrorResult(err.message);
781
950
  }
@@ -819,12 +988,19 @@ export async function runProbedStoriesWaveTick({
819
988
  inFlight,
820
989
  blockedIds = [],
821
990
  foreignHeld = [],
991
+ inFlightRecords = [],
822
992
  } = probed;
823
993
  const { envelope, exitCode } = buildReadySetEnvelope(nodes, {
824
994
  concurrencyCap,
825
995
  capPrecedence,
826
996
  doneIds,
827
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),
828
1004
  });
829
1005
 
830
1006
  const done = [...doneIds].sort((a, b) => a - b);
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env node
2
- // cli-opt-out: top-level main()-driven CLI invoked via npm run coverage:update; no runAsCli() wrapper required.
3
2
  /**
4
3
  * Refresh `baselines/coverage.json` from the most recent
5
4
  * `coverage/coverage-final.json`. Run this when you intentionally add,
@@ -22,6 +21,7 @@ import { createRequire } from 'node:module';
22
21
  import path from 'node:path';
23
22
  import { parseDiffScopeFlag } from './lib/baselines/diff-scope-cli.js';
24
23
  import { refreshBaseline } from './lib/baselines/refresh-service.js';
24
+ import { runAsCli } from './lib/cli-utils.js';
25
25
  import { getBaselineEpsilon } from './lib/config/quality.js';
26
26
  import {
27
27
  buildScopePredicate,
@@ -31,6 +31,32 @@ import {
31
31
  } from './lib/coverage-baseline.js';
32
32
  import { Logger } from './lib/Logger.js';
33
33
 
34
+ /**
35
+ * Usage block for `--help`. This CLI *writes* on invocation, so the help
36
+ * branch must short-circuit before `main` runs rather than inside it —
37
+ * `runAsCli` answers help first, which makes "a usage probe never mutates a
38
+ * baseline" structural instead of a check `main` has to remember.
39
+ */
40
+ const USAGE = {
41
+ invocation:
42
+ 'node .agents/scripts/update-coverage-baseline.js [--full-scope | --diff-scope <ref>]',
43
+ summary:
44
+ 'Score → write the coverage baseline from the coverage-final.json already on disk. With no scope flag the refresh is scoped to the files changed in `origin/main..HEAD`; out-of-scope rows are preserved verbatim.',
45
+ flags: [
46
+ [
47
+ '--full-scope',
48
+ 'Rescore every file in every target dir (no out-of-scope merge).',
49
+ ],
50
+ [
51
+ '--diff-scope <ref>',
52
+ 'Scope the refresh to files changed between <ref> and HEAD. Incompatible with --full-scope.',
53
+ ],
54
+ ],
55
+ notes: [
56
+ 'Run `npm run test:coverage` first — this script never runs the suite itself.',
57
+ ],
58
+ };
59
+
34
60
  const require = createRequire(import.meta.url);
35
61
 
36
62
  function loadC8Scope(cwd) {
@@ -123,7 +149,11 @@ function main() {
123
149
  });
124
150
  }
125
151
 
126
- main().catch((err) => {
127
- Logger.error(`[Coverage] ❌ Fatal error: ${err?.message ?? err}`);
128
- process.exit(1);
152
+ runAsCli(import.meta.url, main, {
153
+ source: 'coverage-baseline',
154
+ usage: USAGE,
155
+ onError: (err) => {
156
+ Logger.error(`[Coverage] ❌ Fatal error: ${err?.message ?? err}`);
157
+ process.exitCode = 1;
158
+ },
129
159
  });