mandrel 2.32.0 → 2.34.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 (48) hide show
  1. package/.agents/docs/SDLC.md +8 -5
  2. package/.agents/docs/agentrc-reference.json +2 -1
  3. package/.agents/docs/configuration.md +3 -2
  4. package/.agents/runtime-deps.json +2 -1
  5. package/.agents/schemas/agentrc.schema.json +8 -2
  6. package/.agents/scripts/README.md +9 -0
  7. package/.agents/scripts/audit-to-stories.js +160 -41
  8. package/.agents/scripts/check-knip-entries.js +47 -24
  9. package/.agents/scripts/check-lifecycle-lint.js +72 -12
  10. package/.agents/scripts/coverage-capture.js +7 -1
  11. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +81 -34
  12. package/.agents/scripts/lib/audit-to-stories/wire-dependencies.js +185 -0
  13. package/.agents/scripts/lib/baselines/kernel.js +20 -7
  14. package/.agents/scripts/lib/baselines/kinds/mutation.js +144 -14
  15. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +10 -7
  16. package/.agents/scripts/lib/config/quality.js +7 -0
  17. package/.agents/scripts/lib/config/runners.js +38 -16
  18. package/.agents/scripts/lib/config-settings-schema-delivery.js +10 -2
  19. package/.agents/scripts/lib/coverage-capture-incremental.js +9 -2
  20. package/.agents/scripts/lib/coverage-capture-usage.js +55 -0
  21. package/.agents/scripts/lib/coverage-capture.js +10 -15
  22. package/.agents/scripts/lib/dependency-parser.js +20 -7
  23. package/.agents/scripts/lib/findings/provenance-field.js +135 -0
  24. package/.agents/scripts/lib/findings/route-finding.js +57 -8
  25. package/.agents/scripts/lib/knip-config-resolver.js +181 -0
  26. package/.agents/scripts/lib/knip-entry-sync.js +78 -39
  27. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +1 -26
  28. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +69 -5
  29. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +93 -19
  30. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +49 -0
  31. package/.agents/scripts/lib/orchestration/resolve-stories.js +72 -35
  32. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +116 -1
  33. package/.agents/scripts/lib/orchestration/ticket-validator.js +38 -0
  34. package/.agents/scripts/lib/story-body/footer-block.js +97 -0
  35. package/.agents/scripts/lib/story-body/story-body.js +6 -22
  36. package/.agents/scripts/lib/wave-runner/footprint.js +306 -0
  37. package/.agents/scripts/lib/wave-runner/ready-set.js +198 -181
  38. package/.agents/scripts/providers/github/blocked-by-add.js +25 -10
  39. package/.agents/scripts/resolve-stories.js +21 -5
  40. package/.agents/scripts/stories-wave-tick.js +192 -9
  41. package/.agents/workflows/audit-to-stories.md +26 -0
  42. package/.agents/workflows/helpers/deliver-light.md +5 -2
  43. package/.agents/workflows/helpers/deliver-reference.md +28 -1
  44. package/.agents/workflows/helpers/deliver-story-reference.md +80 -1
  45. package/.agents/workflows/helpers/deliver-story.md +4 -2
  46. package/.agents/workflows/helpers/plan-reference.md +76 -0
  47. package/docs/CHANGELOG.md +26 -0
  48. package/package.json +3 -3
@@ -55,7 +55,8 @@
55
55
  * inFlight: number,
56
56
  * cycleError: string | null,
57
57
  * wedged: { reason, stories: [{ id, unmetBlockers }] } | null,
58
- * inFlightReservation: { available, withheld: [{ id, blockedBy, reason }], note }
58
+ * inFlightReservation: { available, withheld: [{ id, blockedBy, reason, source, paths }], note },
59
+ * footprintGuard: { mode, withheld: [{ id, blockedBy, scope, source, paths }], advisory, note }
59
60
  * }
60
61
  *
61
62
  * `inFlightReservation` reports the cross-beat half of the co-dispatch guard
@@ -70,6 +71,17 @@
70
71
  * `available: false` rather than an empty — and therefore indistinguishable —
71
72
  * result.
72
73
  *
74
+ * `footprintGuard` reports the **beat-local** half, which until Story #5044 was
75
+ * reported nowhere: a same-beat overlap skip was a bare `continue` inside
76
+ * `planReadySet`, so the Story vanished from `ready[]` with no field anywhere
77
+ * naming the collision. Every entry in either report now also carries the
78
+ * colliding `paths` and a `source` tag — `declared-overlap` when both Stories'
79
+ * `changes[]` named the path (intended serialization: two Stories really do
80
+ * rewrite the same generated baseline) versus `scraped-overlap` when only the
81
+ * text evidence produced it. `mode` names the `footprintGuard` config value;
82
+ * under `advisory` the collisions are detected and listed in `advisory[]` but
83
+ * dispatch follows the declared `depends_on` edges alone.
84
+ *
73
85
  * Probe mode adds fields the caller can no longer compute for itself:
74
86
  * `done: number[]` (the resolved done set, in-set ∪ satisfied foreign
75
87
  * blockers), `epilogueDue: boolean` (true exactly when every listed Story
@@ -111,19 +123,24 @@ import { readFileSync } from 'node:fs';
111
123
  import { parseArgs } from 'node:util';
112
124
 
113
125
  import { runAsCli } from './lib/cli-utils.js';
114
- import { getRunners, resolveConfig } from './lib/config-resolver.js';
126
+ import { getPaths, getRunners, resolveConfig } from './lib/config-resolver.js';
115
127
  import { detectCycle } from './lib/Graph.js';
116
128
  import { Logger } from './lib/Logger.js';
117
129
  import { AGENT_LABELS } from './lib/label-constants.js';
118
130
  import { parseIds } from './lib/orchestration/resolve-stories.js';
119
131
  import { buildStoryAdjacency } from './lib/story-adjacency.js';
120
132
  import { expandIdList } from './lib/util/parse-id-list.js';
133
+ import { OVERLAP_SOURCES } from './lib/wave-runner/footprint.js';
121
134
  import {
122
135
  createProbeContext,
123
136
  probeLiveState,
124
137
  validateProbeFlags,
125
138
  } from './lib/wave-runner/live-probe.js';
126
- import { planReadySet } from './lib/wave-runner/ready-set.js';
139
+ import {
140
+ GUARD_MODES,
141
+ planReadySet,
142
+ WITHHOLD_SCOPES,
143
+ } from './lib/wave-runner/ready-set.js';
127
144
 
128
145
  /**
129
146
  * Exit code for a wedged run — deliberately distinct from the cycle exit (2)
@@ -214,7 +231,29 @@ Output envelope:
214
231
  "wedged": null,
215
232
  "inFlightReservation": {
216
233
  "available": true,
217
- "withheld": [{ "id": 4951, "blockedBy": 4949 }],
234
+ "withheld": [
235
+ {
236
+ "id": 4951,
237
+ "blockedBy": 4949,
238
+ "reason": "in-flight-earlier-beat",
239
+ "source": "declared-overlap",
240
+ "paths": ["lib/shared.js"]
241
+ }
242
+ ],
243
+ "note": "..."
244
+ },
245
+ "footprintGuard": {
246
+ "mode": "enforce",
247
+ "withheld": [
248
+ {
249
+ "id": 4952,
250
+ "blockedBy": 4951,
251
+ "scope": "beat",
252
+ "source": "scraped-overlap",
253
+ "paths": ["lib/other.js"]
254
+ }
255
+ ],
256
+ "advisory": [],
218
257
  "note": "..."
219
258
  }
220
259
  }
@@ -225,6 +264,15 @@ blocking id — so an unfilled slot is explained rather than mysterious. It need
225
264
  the in-flight Stories' footprints, which only --probe-live has: under --dag the
226
265
  report is { available: false } and selection de-conflicts within the beat only.
227
266
 
267
+ footprintGuard names each Story withheld from THIS beat by a peer already
268
+ admitted on it — the half that used to be an unreported skip — and every
269
+ entry in either report carries the colliding paths plus a source tag
270
+ (declared-overlap when both changes[] declarations named the path, else
271
+ scraped-overlap from the text evidence). Its "mode" echoes
272
+ delivery.deliverRunner.footprintGuard: under "advisory" the collisions are
273
+ detected and listed in "advisory" but never withhold, and dispatch follows the
274
+ declared depends_on edges alone.
275
+
228
276
  Exit codes:
229
277
  0 - Success, ready set emitted
230
278
  1 - Invalid input (missing/malformed DAG, invalid --concurrency/--in-flight/--done)
@@ -258,6 +306,7 @@ function inputErrorResult(message, concurrencyCap = null, inFlightValue = 0) {
258
306
  cycleError: null,
259
307
  wedged: null,
260
308
  inFlightReservation: null,
309
+ footprintGuard: null,
261
310
  inputError: message,
262
311
  },
263
312
  exitCode: 1,
@@ -299,10 +348,18 @@ const RESERVATION_REASONS = Object.freeze({
299
348
  * and no later beat of this run will clear it. `foreignHeldIds` splits the two
300
349
  * so each carries its own reason (Story #4960).
301
350
  *
351
+ * Each entry also carries the **colliding paths** and an
352
+ * `OVERLAP_SOURCES` tag (Story #5044). A withhold that names no path is one an
353
+ * operator cannot act on, and `declared-overlap` vs `scraped-overlap` is the
354
+ * difference between "these two Stories both declared this generated baseline,
355
+ * serializing them is the point" and "one Story's body happened to mention a
356
+ * path the other declared" — the same unfilled slot for two very different
357
+ * reasons.
358
+ *
302
359
  * @param {object[]|null|undefined} inFlightRecords
303
- * @param {Array<{id: number, blockedBy: number}>} withheld
360
+ * @param {Array<{id: number, blockedBy: number, source?: string, paths?: string[]}>} withheld
304
361
  * @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 }}
362
+ * @returns {{ available: boolean, withheld: Array<{id: number, blockedBy: number, reason: string, source: string, paths: string[]}>, note: string|null }}
306
363
  */
307
364
  export function buildReservationReport(
308
365
  inFlightRecords,
@@ -326,10 +383,13 @@ export function buildReservationReport(
326
383
  }
327
384
  const foreign = new Set(foreignHeldIds);
328
385
  const classified = withheld.map((w) => ({
329
- ...w,
386
+ id: w.id,
387
+ blockedBy: w.blockedBy,
330
388
  reason: foreign.has(w.blockedBy)
331
389
  ? RESERVATION_REASONS.FOREIGN_LEASE
332
390
  : RESERVATION_REASONS.EARLIER_BEAT,
391
+ source: w.source ?? OVERLAP_SOURCES.DECLARED,
392
+ paths: w.paths ?? [],
333
393
  }));
334
394
  return {
335
395
  available: true,
@@ -338,6 +398,87 @@ export function buildReservationReport(
338
398
  };
339
399
  }
340
400
 
401
+ /**
402
+ * Report the **beat-local** half of the footprint guard, plus the guard mode
403
+ * itself (Story #5044).
404
+ *
405
+ * Until now a same-beat overlap skip was an anonymous `continue` inside
406
+ * `planReadySet`: the Story simply did not appear in `ready[]` and no field
407
+ * anywhere said why. That is the same unexplained-unfilled-slot failure the
408
+ * cross-beat `inFlightReservation` report was built to remove, left standing on
409
+ * the other half of the guard — and it is how a plan whose siblings collided
410
+ * only on machine-generated footer text ran fully serial without leaving a
411
+ * trace to notice.
412
+ *
413
+ * `withheld` and `advisory` are disjoint by construction: under `enforce` every
414
+ * detection withheld, under `advisory` none did. Reporting them as separate
415
+ * lists rather than one flagged list means a consumer counting withheld
416
+ * dispatches never has to inspect a boolean to get the count right.
417
+ *
418
+ * @param {Array<object>} footprintWithholds The kernel's complete ledger.
419
+ * @param {'enforce'|'advisory'} mode
420
+ * @returns {{ mode: string, withheld: object[], advisory: object[], note: string|null }}
421
+ */
422
+ export function buildFootprintGuardReport(footprintWithholds, mode) {
423
+ const ledger = Array.isArray(footprintWithholds) ? footprintWithholds : [];
424
+ const project = ({ id, blockedBy, scope, source, paths }) => ({
425
+ id,
426
+ blockedBy,
427
+ scope,
428
+ source,
429
+ paths,
430
+ });
431
+ const beat = ledger
432
+ .filter((w) => w.scope === WITHHOLD_SCOPES.BEAT && w.enforced)
433
+ .map(project);
434
+ const advisory = ledger.filter((w) => !w.enforced).map(project);
435
+ return {
436
+ mode,
437
+ withheld: beat,
438
+ advisory,
439
+ note: footprintGuardNote(beat, advisory, mode),
440
+ };
441
+ }
442
+
443
+ /**
444
+ * Render the operator-facing note for the beat-local guard, naming the
445
+ * colliding path(s) for every entry. `null` when the guard neither withheld nor
446
+ * waved anything through.
447
+ *
448
+ * @param {object[]} beat
449
+ * @param {object[]} advisory
450
+ * @param {string} mode
451
+ * @returns {string|null}
452
+ */
453
+ function footprintGuardNote(beat, advisory, mode) {
454
+ const detail = (entries) =>
455
+ entries
456
+ .map(
457
+ (w) =>
458
+ `#${w.id} ← #${w.blockedBy} on ${w.paths.join(', ')} (${w.source})`,
459
+ )
460
+ .join('; ');
461
+ if (beat.length > 0) {
462
+ return (
463
+ `${beat.length} Story(ies) withheld from THIS beat because their file ` +
464
+ `footprint overlaps a peer already admitted on it — ${detail(beat)}. ` +
465
+ `Each is still eligible and re-admits on a later beat once its peer ` +
466
+ `lands. A ${OVERLAP_SOURCES.SCRAPED} source means the collision came ` +
467
+ `from path evidence in the Story text rather than from either ` +
468
+ `changes[] declaration.`
469
+ );
470
+ }
471
+ if (advisory.length > 0) {
472
+ return (
473
+ `footprintGuard is '${mode}': ${advisory.length} footprint collision(s) ` +
474
+ `were detected and NOT enforced — ${detail(advisory)}. Dispatch followed ` +
475
+ `the declared depends_on edges alone. Set ` +
476
+ `delivery.deliverRunner.footprintGuard: 'enforce' to serialize these.`
477
+ );
478
+ }
479
+ return null;
480
+ }
481
+
341
482
  /**
342
483
  * Render the operator-facing reservation note, one sentence per reason class
343
484
  * present. Neither class is a failure or a wedge, but they clear by different
@@ -600,6 +741,29 @@ export function resolveCapPrecedence({ cwd, config, override } = {}) {
600
741
  };
601
742
  }
602
743
 
744
+ /**
745
+ * Resolve the two footprint-guard inputs from the same config seam the cap
746
+ * comes from (Story #5044).
747
+ *
748
+ * `tempRoot` is threaded rather than hardcoded because the evidence scrape must
749
+ * exclude the project's *configured* scratch root: a consumer that sets
750
+ * `project.paths.tempRoot: '.scratch'` would otherwise have every sibling
751
+ * citing a report under it collide, which is the exact defect this Story
752
+ * removes for the default `temp/`.
753
+ *
754
+ * @param {object} [opts]
755
+ * @param {string} [opts.cwd] Repo root for config resolution.
756
+ * @param {object} [opts.config] Pre-resolved config (test injection).
757
+ * @returns {{ footprintGuard: 'enforce'|'advisory', tempRoot: string }}
758
+ */
759
+ export function resolveFootprintGuardSettings({ cwd, config } = {}) {
760
+ const resolved = config ?? resolveConfig({ cwd });
761
+ return {
762
+ footprintGuard: getRunners(resolved).deliverRunner.footprintGuard,
763
+ tempRoot: getPaths(resolved).tempRoot,
764
+ };
765
+ }
766
+
603
767
  /**
604
768
  * Build the per-beat ready-set envelope from a validated DAG.
605
769
  *
@@ -648,6 +812,8 @@ export function buildReadySetEnvelope(
648
812
  inFlight = 0,
649
813
  inFlightRecords = null,
650
814
  foreignHeldIds = [],
815
+ footprintGuard = GUARD_MODES.ENFORCE,
816
+ tempRoot,
651
817
  },
652
818
  ) {
653
819
  const totalStories = nodes.length;
@@ -668,6 +834,11 @@ export function buildReadySetEnvelope(
668
834
  // which Stories a reservation withheld (Story #4950). Never omitted on a
669
835
  // resolved beat: an absent report reads exactly like an empty one.
670
836
  inFlightReservation: buildReservationReport(inFlightRecords, []),
837
+ // The beat-local half of the same guard, plus the mode it ran in (Story
838
+ // #5044). Also never omitted: a same-beat skip used to be reported nowhere
839
+ // at all, which is precisely how an evidence-widening artifact could
840
+ // serialize a whole run unnoticed.
841
+ footprintGuard: buildFootprintGuardReport([], footprintGuard),
671
842
  };
672
843
 
673
844
  if (totalStories === 0) {
@@ -714,7 +885,7 @@ export function buildReadySetEnvelope(
714
885
  return rec;
715
886
  });
716
887
 
717
- const { selected, withheldByInFlight } = planReadySet({
888
+ const { selected, footprintWithholds, guardMode } = planReadySet({
718
889
  stories: records,
719
890
  doneIds,
720
891
  inFlight,
@@ -723,13 +894,21 @@ export function buildReadySetEnvelope(
723
894
  // contract (an array) while `base.inFlightReservation` reports that the
724
895
  // reservation itself was unavailable rather than merely empty.
725
896
  inFlightRecords: inFlightRecords ?? [],
897
+ footprintGuard,
898
+ tempRoot,
726
899
  });
727
900
  const ready = selected.map((rec) => rec.id);
901
+ // The cross-beat report reads the enforced in-flight slice of the kernel's
902
+ // ledger rather than the legacy `withheldByInFlight` list, so it carries the
903
+ // colliding paths and the overlap source through to the operator.
728
904
  const reservation = buildReservationReport(
729
905
  inFlightRecords,
730
- withheldByInFlight,
906
+ footprintWithholds.filter(
907
+ (w) => w.scope === WITHHOLD_SCOPES.IN_FLIGHT && w.enforced,
908
+ ),
731
909
  foreignHeldIds,
732
910
  );
911
+ const guardReport = buildFootprintGuardReport(footprintWithholds, guardMode);
733
912
 
734
913
  // Wedge detection (Story #4540). `ready: []` is normal while work is in
735
914
  // flight — the loop is simply waiting. But ready-empty AND nothing in
@@ -750,6 +929,7 @@ export function buildReadySetEnvelope(
750
929
  ready,
751
930
  wedged: wedge,
752
931
  inFlightReservation: reservation,
932
+ footprintGuard: guardReport,
753
933
  },
754
934
  exitCode: WEDGED_EXIT_CODE,
755
935
  };
@@ -761,6 +941,7 @@ export function buildReadySetEnvelope(
761
941
  ready,
762
942
  wedged: null,
763
943
  inFlightReservation: reservation,
944
+ footprintGuard: guardReport,
764
945
  },
765
946
  exitCode: 0,
766
947
  };
@@ -896,6 +1077,7 @@ export function runStoriesWaveTick({
896
1077
  capPrecedence,
897
1078
  doneIds,
898
1079
  inFlight: inFlightValue,
1080
+ ...resolveFootprintGuardSettings({ cwd, config }),
899
1081
  });
900
1082
  }
901
1083
 
@@ -1001,6 +1183,7 @@ export async function runProbedStoriesWaveTick({
1001
1183
  // ...and the only mode that can tell a foreign lease-holder apart from
1002
1184
  // this run's own earlier-beat dispatch (Story #4960).
1003
1185
  foreignHeldIds: foreignHeld.map((h) => h.id),
1186
+ ...resolveFootprintGuardSettings({ cwd, config }),
1004
1187
  });
1005
1188
 
1006
1189
  const done = [...doneIds].sort((a, b) => a - b);
@@ -189,6 +189,32 @@ Labels applied:
189
189
  (cross-audit groups carry multiple).
190
190
  - `risk::high` — added when any finding in the group is Critical.
191
191
 
192
+ ### Phase 5c — Wire the cohort's declared ordering (**required**)
193
+
194
+ Creating the Issues is only the first pass. `groupFindings` detects `edges[]`
195
+ between groups, but at emit time no group has an issue number, so each body
196
+ ships with an empty `depends_on` and the cohort has **no declared ordering
197
+ at all**. Replay the numbers you just opened:
198
+
199
+ ```bash
200
+ node .agents/scripts/audit-to-stories.js --wire-edges \
201
+ --plan temp/audits/audit-to-stories-plan.json \
202
+ --ids '{"<groupKey>": <issueNumber>, ...}' \
203
+ --out temp/audits/audit-to-stories-wired.json
204
+ ```
205
+
206
+ Each entry in the emitted `--json` payload carries its own `groupKey` and
207
+ `dependsOn`, so the map is a lookup, not a reconstruction. The pass re-renders
208
+ every Story that has a resolvable blocker with a canonical
209
+ `---` / `blocked by #N` footer **and** mirrors the same edges as native
210
+ GitHub `blocked_by` relations. An edge whose target was never opened (deduped,
211
+ ledger-suppressed) drops rather than becoming a `blocked by #undefined`.
212
+
213
+ **Do not skip this.** `/deliver` has no other source for this cohort's order:
214
+ its footprint guard ignores the shared provenance footers, so an unwired cohort
215
+ is genuinely unordered and `/deliver` will co-dispatch Stories the edges say
216
+ must follow one another.
217
+
192
218
  ## Phase 6 — Idempotency (folded into Phase 1 scan)
193
219
 
194
220
  The `--scan` step routes each group's findings through the shared
@@ -131,8 +131,11 @@ answer).
131
131
  invoked, not reimplemented.
132
132
 
133
133
  3. **Implement + self-eval.** `cd` into `workCwd`, implement the change, run
134
- `npm test` once in the worktree, then run the bounded acceptance self-eval
135
- loop ([`deliver-story.md`](deliver-story.md) Step 1a). Commit
134
+ the full suite once in the worktree **so close can credit it** — the
135
+ crediting invocation and the freshness contract are
136
+ [`deliver-story.md`](deliver-story.md) Step 1.3, unchanged here — then run
137
+ the bounded acceptance self-eval loop
138
+ ([`deliver-story.md`](deliver-story.md) Step 1a). Commit
136
139
  on `story-<id>` with `(refs #<storyId>)`.
137
140
 
138
141
  4. **Diff backstop.** Before close, re-check the ACTUAL diff:
@@ -94,7 +94,7 @@ probe logs a warning and leans on init's lease refusal alone.
94
94
  **Overlapping footprints are reserved across beats, not just within one.** A
95
95
  Story sharing a **concrete** path with a still-implementing Story is withheld
96
96
  and named in `inFlightReservation: { available, withheld: [{ id, blockedBy,
97
- reason }], note }`, where `reason` is `in-flight-earlier-beat` or
97
+ reason, source, paths }], note }`, where `reason` is `in-flight-earlier-beat` or
98
98
  `foreign-lease`. Like `foreignHeld` this is neither a failure nor a wedge — the
99
99
  Story re-admits automatically once its blocker leaves the in-flight set — and
100
100
  it exists so an unfilled slot is explained rather than mysterious. A **glob**
@@ -103,6 +103,33 @@ across beats; it still serializes its own beat. Reservation needs the in-flight
103
103
  Stories' footprints, so it is a `--probe-live` capability: under `--dag` the
104
104
  report is `available: false` and selection de-conflicts within the beat only.
105
105
 
106
+ **Beat-local skips are reported too**, in `footprintGuard: { mode, withheld,
107
+ advisory, note }`. They used to be an unreported skip, so a Story simply
108
+ vanished from `ready[]` and an unfilled slot read exactly like a cap that was
109
+ never reached. Every entry in **either** report carries the colliding `paths`
110
+ and a `source` tag:
111
+
112
+ - `declared-overlap` — both Stories' `changes[]` named the path (or a declared
113
+ glob). Intended serialization; two Stories rewriting the same generated
114
+ baseline must not co-dispatch.
115
+ - `scraped-overlap` — only the text evidence produced it. Real signal — a
116
+ declaration is only a lower bound — but the class where a false positive is
117
+ possible.
118
+
119
+ **The evidence scrape excludes exactly three token sources**, each structurally
120
+ incapable of naming an edit target: `audit-fingerprints` /
121
+ `audit-semantic-keys` provenance footers, paths under `project.paths.tempRoot`,
122
+ and markdown-link URL interiors. Nothing else is stripped — a
123
+ `<!-- DECOMPOSITION -->` block's paths are genuine intent
124
+ ([`instructions.md` § 7](../../instructions.md)) and still count.
125
+
126
+ **`delivery.deliverRunner.footprintGuard`** selects what a collision does:
127
+
128
+ | Mode | Effect |
129
+ | --- | --- |
130
+ | `enforce` (default) | A collision withholds the Story. Keep this unless you have a reason — the guard encodes delivery-time-only knowledge (open implementation windows, foreign leases, ground moved since planning) no `depends_on` edge can carry. |
131
+ | `advisory` | Collisions are still **detected** and listed in `footprintGuard.advisory`, but never withhold; dispatch follows the declared `depends_on` edges alone. A throughput trade for a run whose ordering is fully declared. |
132
+
106
133
  ## Dispatch mechanics (role-scoped by default)
107
134
 
108
135
  **A single-Story run executes inline.** Sub-agent isolation is
@@ -161,6 +161,63 @@ terminal envelope are byte-identical either way.
161
161
 
162
162
  ---
163
163
 
164
+ ## Declared dependency edges — what actually gates dispatch
165
+
166
+ `resolve-stories.js` builds each `dag[].dependsOn` from the **union of two
167
+ declared-edge channels**, and nothing else: the Story body's footer block and
168
+ the issue's native GitHub `blocked_by` relations. Both are read strictly — an
169
+ edge the resolver cannot read is never quietly reported as an edge that does
170
+ not exist.
171
+
172
+ **The body channel is footer-scoped.** Only a `blocked by #N` line standing
173
+ alone inside the `---` footer block declares an edge:
174
+
175
+ ```markdown
176
+ ## Goal
177
+
178
+
179
+
180
+ ---
181
+
182
+ blocked by #42
183
+ ```
184
+
185
+ Prose elsewhere in the body declares **nothing**, and this is a deliberate,
186
+ user-visible change from the whole-body scan that preceded it. A sentence
187
+ merely mentioning a blocker — an example, a changelog note, an acceptance
188
+ criterion quoting the phrase — used to mint a real dispatch gate that withheld
189
+ the Story until an unrelated issue closed. `plan-persist` has always
190
+ serialized the canonical footer form, so no machine-authored body is affected;
191
+ only a **hand-written prose edge** stops gating, and the fix is to move it into
192
+ the footer block. The loose spellings never reached the footer grammar either:
193
+ `depends on #N`, `Blocked by: #N`, and `blocked by #N once X lands` all declare
194
+ nothing. One grammar serves both readers — the body parser and the
195
+ dispatch-edge parser share it — so what a Story body round-trips and what gates
196
+ dispatch cannot drift apart.
197
+
198
+ **The native channel fails loud.** The read paginates to exhaustion (a
199
+ first-page read silently truncated a Story's gates at GitHub's 30-item
200
+ default), and **a 404 is not an empty result**. An issue with no dependencies
201
+ answers `200 []`; a 404 is how GitHub also answers a token that cannot see the
202
+ dependencies API, so treating it as "no edges" erased every native edge in the
203
+ run under a mis-scoped token, silently, with a clean exit code. Any non-OK
204
+ read now fails the resolution naming the Story — check the token's scopes
205
+ first. The one degrade that is scoped rather than fatal is a **cross-repo
206
+ edge**: another repository's issue number cannot be matched against this
207
+ repo's same-numbered issue without risking a false match, so that edge is
208
+ dropped with a warning naming the Story, and its siblings resolve normally.
209
+
210
+ **Edges are monotone — retraction is not built.** Both channels only ever
211
+ _add_ a gate for the current resolution. Removing a `blocked by` footer line
212
+ or deleting a native relation makes the edge absent from the **next** resolve,
213
+ but nothing reconciles an edge that a previous run already acted on, and the
214
+ write path never deletes a native relation it did not need. In practice that
215
+ means: re-resolve after editing edges, and treat a stale gate as a body/issue
216
+ edit plus a fresh `resolve-stories.js` run, never as something delivery
217
+ un-declares on your behalf. This is a known limitation, not an oversight.
218
+
219
+ ---
220
+
164
221
  ## Step 1 — Implementation detail
165
222
 
166
223
  **Docs context — digest-first.** Read a full doc only when the Story's own
@@ -176,12 +233,34 @@ runs maker-blind at Story-scope review inside the close subprocess. The
176
233
  dispatch step produces `checklistPath` from the Story's predicted footprint
177
234
  before it spawns the worker — see [`/deliver`](../deliver.md).
178
235
 
179
- **Pre-eval full-suite discipline (spine step 5).** Repo-invariant guards —
236
+ **Pre-eval full-suite discipline (spine step 1.3).** Repo-invariant guards —
180
237
  drift-guard and schema tests living outside the Story's scoped greps — are
181
238
  the failure class that actually bounces deliveries: close-validation
182
239
  discovers them only after the whole close pipeline has run, at several times
183
240
  the cost of one pre-eval full-suite run.
184
241
 
242
+ **Run it so close can credit it.** Close skips a gate that already passed at
243
+ the current HEAD, but a bare `npm test` deposits no such record — the suite
244
+ then runs twice per delivery, once here and once in the close gate chain.
245
+ Pick the invocation by the same predicate `close-validation/gates.js` uses to
246
+ choose its test gate:
247
+
248
+ ```bash
249
+ # CRAP gate enabled (default) + a `test:coverage` script — writes the stamp
250
+ # the close `coverage-capture` gate reads:
251
+ node <main-repo>/.agents/scripts/coverage-capture.js --cwd <workCwd>
252
+ # otherwise — the evidence record the close `test` gate reads. <workCwd> must
253
+ # be ABSOLUTE and the runner exactly `npm test`: both sides hash
254
+ # {cmd, args, cwd}, so a relative path or a wrapper misses the credit.
255
+ node <main-repo>/.agents/scripts/evidence-gate.js --standalone \
256
+ --scope-id <storyId> --gate test --worktree <workCwd> -- npm test
257
+ ```
258
+
259
+ The credit expires the moment it stops describing the tree: evidence is keyed
260
+ on HEAD, the capture stamp on a content digest of `crap.targetDirs`. A
261
+ self-eval fix — or any commit — invalidates it and close re-runs the suite for
262
+ real, so this never trades away the gate.
263
+
185
264
  **Conflict with `main` mid-implementation** → resolve as you would any branch
186
265
  rebase. There is no `epic/<id>` intermediate, so the rebase base is `main`
187
266
  directly.
@@ -75,9 +75,11 @@ One branch, one PR to `main`, commits against the inline `acceptance[]` /
75
75
  `## Slicing` rows as **intra-session checkpoints** (reference § Step 1).
76
76
  2. Implement and commit on the Story branch, iterating with quick advisory
77
77
  gates (`typecheck`, `lint`, scoped tests) — the full chain runs in Step 3.
78
- 3. Run `npm test` once in the worktree **before Step 1a**: repo-invariant
78
+ 3. Run the full suite once in the worktree **before Step 1a**: repo-invariant
79
79
  guards outside the Story's scoped greps are the failure class that bounces
80
- deliveries. Fix and commit first, then run the self-eval loop.
80
+ deliveries. Fix and commit first, then run the self-eval loop. Run it **so
81
+ Step 3 credits it** — a bare `npm test` records nothing, so close re-runs
82
+ the identical suite (reference § Step 1, "Pre-eval full-suite discipline").
81
83
 
82
84
  ### Step 1a — Bounded acceptance self-eval loop (**required**)
83
85
 
@@ -243,6 +243,82 @@ as a hard error — so the grounding contract is the author's own targeted reads
243
243
  plus that gate. There is no pre-computed codebase snapshot to fall back on,
244
244
  and no manifest-derived replacement to build.
245
245
 
246
+ ### Per-Story audit provenance (`provenance`)
247
+
248
+ An audit-seeded plan carries dedup identities forward so the next sweep
249
+ recognises what it already planned. The optional top-level `provenance` field
250
+ says **which of them this Story owns**:
251
+
252
+ ```jsonc
253
+ {
254
+ "slug": "own-the-seam",
255
+ "provenance": {
256
+ "fingerprints": ["<40-char sha1, one per finding this Story tracks>"],
257
+ "semanticKeys": ["architecture␟lib/owned.js"]
258
+ }
259
+ }
260
+ ```
261
+
262
+ Both arrays are optional; a malformed entry is a validator rejection, never a
263
+ silent drop — a dropped identity is invisible until the next sweep re-files
264
+ work this plan already tracked.
265
+
266
+ | `provenance` | What persist stamps |
267
+ | --- | --- |
268
+ | Present | **Exactly** the identities listed — siblings' groups never leak in. |
269
+ | Present but empty (`{}`) | Nothing. "Owns no findings" is a real answer. |
270
+ | **Absent** | The **whole seed's** footers (the union) — the recall-safe default. |
271
+
272
+ **The union fallback is load-bearing, not legacy.** Leaving the authoring agent
273
+ to hand-carry provenance out of the seed's HTML comments was measured to fail —
274
+ a remembered step is no step at all — and the mechanical union carry is what
275
+ closed it. Attribution is additive: it sharpens a plan that opts in and changes
276
+ nothing for one that does not. Never remove the fallback to "finish the
277
+ migration".
278
+
279
+ Attribution is what makes the next sweep's dedup answerable rather than
280
+ arbitrary. Under the union every sibling carried every key, so a finding
281
+ confirming against several open Stories could only pick one at random, and a
282
+ key whose owning Story had since **closed** was masked by any open neighbour —
283
+ a genuine regression filed as a routine update. With ownership stamped, the
284
+ issue carrying a finding's own fingerprint decides both the match and its
285
+ state (`lib/findings/route-finding.js`).
286
+
287
+ The audit path authors this mechanically from the per-group footers the seed
288
+ already carries — see [`audit-to-stories`](../audit-to-stories.md). A `--seed`
289
+ or `--tickets` plan has nothing to attribute and omits the field.
290
+
291
+ ## Cross-Story conflict analysis at persist
292
+
293
+ The conflict passes run **twice**: once over the raw `stories.json` payload
294
+ (alongside the freshness, file-assumption and sizing gates), and again over the
295
+ **assembled, footer-stamped bodies** — the artifact persist actually posts.
296
+ The second pass is not belt-and-braces. The canonical authoring shape carries
297
+ `acceptance[]` / `verify[]` at the ticket's top level and assembly folds them
298
+ into the body, so the passes that scan `body.acceptance` / `body.verify`
299
+ (`implicit-cross-story-dep`, `missing-bdd-scaffold`) saw two empty arrays on
300
+ the real payload and emitted nothing. Both passes complete before the first
301
+ `createIssue`, so a refusal still costs no writes.
302
+
303
+ `shared-editor` findings are rendered into the posted `plan-summary` comment,
304
+ directly beneath the wave table: the table promises which Stories can run
305
+ together, and a path two same-wave Stories both write is exactly where that
306
+ promise breaks. Promise and caveat belong on one durable surface — previously
307
+ the caveat was a stderr warning nobody kept.
308
+
309
+ Two `planning.*` knobs upgrade a conflict class from advisory to a hard
310
+ refusal. **Both default to `false` and are documented, not recommended:**
311
+
312
+ | Knob | Upgrades | Why it is off |
313
+ | --- | --- | --- |
314
+ | `planning.failOnSharedEditors` | `shared-editor` → `hard` | Co-editing one file is routine and often correct; the delivery scheduler already serializes file-overlapping Stories. |
315
+ | `planning.requireExplicitCrossStoryDeps` | `implicit-cross-story-dep` → `hard` | Path references are matched by substring, so a legitimate mention in prose can read as a dependency. |
316
+
317
+ Turn one on for a repo where the class is genuinely fatal; expect a refusal to
318
+ name the Stories and the fix (a `depends_on` edge, or folding the shared edit
319
+ into one Story). The sibling knobs `failOnRegistryConflicts`,
320
+ `failOnMissingBddScaffold` and `failOnLargeFanOut` behave the same way.
321
+
246
322
  ## Tickets mode — authoring `supersedes[]`
247
323
 
248
324
  In `--tickets` mode each Story carries a top-level `supersedes` array claiming
package/docs/CHANGELOG.md CHANGED
@@ -15,6 +15,32 @@ All notable changes to this project will be documented in this file.
15
15
  -->
16
16
  <!-- markdownlint-disable-file MD004 MD012 MD037 -->
17
17
 
18
+ ## [2.34.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.33.0...mandrel-v2.34.0) (2026-08-27)
19
+
20
+
21
+ ### Fixed
22
+
23
+ * **baselines:** weight the mutation rollup by mutant count and gate the migration (refs [#5058](https://github.com/dsj1984/mandrel/issues/5058)) ([#5062](https://github.com/dsj1984/mandrel/issues/5062)) ([a645d30](https://github.com/dsj1984/mandrel/commit/a645d3085450b4dae39ae5d0a9b9844014535f84))
24
+ * **coverage:** describe incrementalCoverage honestly, drop inert forwarding (refs [#5065](https://github.com/dsj1984/mandrel/issues/5065)) ([#5066](https://github.com/dsj1984/mandrel/issues/5066)) ([04ff450](https://github.com/dsj1984/mandrel/commit/04ff450cb1774a1faa0b4f901f1e09261b0ae00a))
25
+ * delivery lifecycle: credit the Step 1 full-suite run to close and enable incremental coverage by default ([#5063](https://github.com/dsj1984/mandrel/issues/5063)) ([#5064](https://github.com/dsj1984/mandrel/issues/5064)) ([39b1ea4](https://github.com/dsj1984/mandrel/commit/39b1ea421c9a8a43f35ad4c8cbc082e1f2ead8c4))
26
+ * **plan-persist:** re-carry audit provenance onto dependent Stories (refs [#5056](https://github.com/dsj1984/mandrel/issues/5056)) ([#5060](https://github.com/dsj1984/mandrel/issues/5060)) ([548cc8a](https://github.com/dsj1984/mandrel/commit/548cc8a46afc5b643452060cc3a03bf71a874b89))
27
+
28
+ ## [2.33.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.32.0...mandrel-v2.33.0) (2026-08-07)
29
+
30
+
31
+ ### Added
32
+
33
+ * deliver: widen footprints from edit intent only, explain every withhold, and give standalone audit cohorts declared edges ([#5044](https://github.com/dsj1984/mandrel/issues/5044)) ([#5049](https://github.com/dsj1984/mandrel/issues/5049)) ([6bd0653](https://github.com/dsj1984/mandrel/commit/6bd0653e88e07ddbcc90c3c947b5f135374649d4))
34
+ * plan: stamp only the provenance a Story owns, and run cross-Story conflict analysis over the bodies persist actually writes ([#5045](https://github.com/dsj1984/mandrel/issues/5045)) ([#5048](https://github.com/dsj1984/mandrel/issues/5048)) ([804b308](https://github.com/dsj1984/mandrel/commit/804b3087c001f21815aecb109bb636e59a87b2cb))
35
+
36
+
37
+ ### Fixed
38
+
39
+ * check-knip-entries: resolve the entry list through knip's own config resolver, and skip cleanly when there is no knip config ([#5039](https://github.com/dsj1984/mandrel/issues/5039)) ([#5041](https://github.com/dsj1984/mandrel/issues/5041)) ([7f69a18](https://github.com/dsj1984/mandrel/commit/7f69a18ead79da77779981bec3eed8772dc05643))
40
+ * deliver: make the declared-edge channel trustworthy — paginate native reads, fail loud on 404, parse footers strictly ([#5046](https://github.com/dsj1984/mandrel/issues/5046)) ([#5047](https://github.com/dsj1984/mandrel/issues/5047)) ([38b543d](https://github.com/dsj1984/mandrel/commit/38b543dec2634318b2407b54404239e7ae34d381))
41
+ * **lint:** add a --root scan seam so the lint test stops mutating the shared tree (refs [#5052](https://github.com/dsj1984/mandrel/issues/5052)) ([#5053](https://github.com/dsj1984/mandrel/issues/5053)) ([69d1952](https://github.com/dsj1984/mandrel/commit/69d1952fb7410659ca21a9bb0d34eefadb654d2e))
42
+ * plan: resolve the cross-Story conflict policy once for the raw and assembled passes ([#5050](https://github.com/dsj1984/mandrel/issues/5050)) ([711199a](https://github.com/dsj1984/mandrel/commit/711199a861bc1e85218a09fe4674c507b0c0edb1))
43
+
18
44
  ## [2.32.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.31.0...mandrel-v2.32.0) (2026-08-06)
19
45
 
20
46