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.
- package/.agents/docs/SDLC.md +8 -5
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +3 -2
- package/.agents/runtime-deps.json +2 -1
- package/.agents/schemas/agentrc.schema.json +8 -2
- package/.agents/scripts/README.md +9 -0
- package/.agents/scripts/audit-to-stories.js +160 -41
- package/.agents/scripts/check-knip-entries.js +47 -24
- package/.agents/scripts/check-lifecycle-lint.js +72 -12
- package/.agents/scripts/coverage-capture.js +7 -1
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +81 -34
- package/.agents/scripts/lib/audit-to-stories/wire-dependencies.js +185 -0
- package/.agents/scripts/lib/baselines/kernel.js +20 -7
- package/.agents/scripts/lib/baselines/kinds/mutation.js +144 -14
- package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +10 -7
- package/.agents/scripts/lib/config/quality.js +7 -0
- package/.agents/scripts/lib/config/runners.js +38 -16
- package/.agents/scripts/lib/config-settings-schema-delivery.js +10 -2
- package/.agents/scripts/lib/coverage-capture-incremental.js +9 -2
- package/.agents/scripts/lib/coverage-capture-usage.js +55 -0
- package/.agents/scripts/lib/coverage-capture.js +10 -15
- package/.agents/scripts/lib/dependency-parser.js +20 -7
- package/.agents/scripts/lib/findings/provenance-field.js +135 -0
- package/.agents/scripts/lib/findings/route-finding.js +57 -8
- package/.agents/scripts/lib/knip-config-resolver.js +181 -0
- package/.agents/scripts/lib/knip-entry-sync.js +78 -39
- package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +1 -26
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +69 -5
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +93 -19
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +49 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +72 -35
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +116 -1
- package/.agents/scripts/lib/orchestration/ticket-validator.js +38 -0
- package/.agents/scripts/lib/story-body/footer-block.js +97 -0
- package/.agents/scripts/lib/story-body/story-body.js +6 -22
- package/.agents/scripts/lib/wave-runner/footprint.js +306 -0
- package/.agents/scripts/lib/wave-runner/ready-set.js +198 -181
- package/.agents/scripts/providers/github/blocked-by-add.js +25 -10
- package/.agents/scripts/resolve-stories.js +21 -5
- package/.agents/scripts/stories-wave-tick.js +192 -9
- package/.agents/workflows/audit-to-stories.md +26 -0
- package/.agents/workflows/helpers/deliver-light.md +5 -2
- package/.agents/workflows/helpers/deliver-reference.md +28 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +80 -1
- package/.agents/workflows/helpers/deliver-story.md +4 -2
- package/.agents/workflows/helpers/plan-reference.md +76 -0
- package/docs/CHANGELOG.md +26 -0
- 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 {
|
|
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": [
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
|
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
|
|
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
|
|