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
@@ -0,0 +1,181 @@
1
+ /**
2
+ * knip-config-resolver.js — resolve a repository's knip configuration the way
3
+ * knip itself would, and hand back its declared entry patterns.
4
+ *
5
+ * Split out of `knip-entry-sync.js` (Story #5039) because these are two
6
+ * concerns that change for different reasons: *what knip's config says* tracks
7
+ * knip's own releases, while *which CLIs something invokes* tracks this
8
+ * repository's callers. Keeping them in one file also pushed that module's
9
+ * maintainability index below its floor.
10
+ *
11
+ * Why not read the file ourselves: #5026 hardcoded
12
+ * `JSON.parse(<root>/knip.json)`, so all seven of knip's other config
13
+ * locations plus `package.json#knip` resolved to ENOENT and failed the gate
14
+ * closed. That made it unusable in exactly the repositories that adopt the
15
+ * shared `knip.base.json` — knip 6 has no root-level `extends`, so inheriting
16
+ * the base means spreading it inside a TypeScript module, which a static
17
+ * `knip.json` structurally cannot express.
18
+ *
19
+ * Going through `createOptions` also drops the assumption that `entry` is
20
+ * *statically declared*. A config may build the array programmatically, so
21
+ * even a purpose-built `knip.config.ts` parser would read the wrong thing;
22
+ * knip evaluates the module and returns the computed value.
23
+ */
24
+
25
+ /**
26
+ * Load knip's own config resolver.
27
+ *
28
+ * Deliberately a dynamic import behind an injectable seam. `knip` is declared
29
+ * in `runtime-deps.json` as an **optional** dependency, mirroring `typescript`:
30
+ * the `.agents/` payload is materialized into consumers that may not run knip
31
+ * at all, so an unresolvable `knip` must degrade to the skip path rather than
32
+ * crash the gate at module load — and must never be preflight-blocked.
33
+ *
34
+ * @returns {Promise<{ createOptions?: Function }>}
35
+ */
36
+ function importKnipSession() {
37
+ return import('knip/session');
38
+ }
39
+
40
+ /**
41
+ * Collect every declared entry pattern from a resolved knip configuration.
42
+ *
43
+ * Entries live in two places and #5026 read only the first. A pnpm-workspace
44
+ * config — the shape that made this gate unusable in `Beestera/swarm-os` —
45
+ * declares them per workspace, so a config with no top-level `entry` at all is
46
+ * still fully enumerated. A named workspace's patterns are workspace-relative
47
+ * and are joined to the workspace name; the root workspace (`.`) is already
48
+ * repo-relative. A leading `!` is knip's *negation* marker (as opposed to the
49
+ * trailing production marker) and stays at the front of the pattern rather
50
+ * than getting buried behind the workspace prefix.
51
+ *
52
+ * `sawEntryArray` separates "declared nothing under `.agents/scripts/`" — a
53
+ * legitimate result, reported as `missing` divergences — from "this config
54
+ * enumerates no entry points anywhere", which leaves the gate nothing to
55
+ * compare against.
56
+ *
57
+ * @param {object} parsedConfig knip's schema-parsed configuration
58
+ * @returns {{ patterns: string[], sawEntryArray: boolean }}
59
+ */
60
+ function collectEntryPatterns(parsedConfig) {
61
+ const patterns = [];
62
+ let sawEntryArray = false;
63
+
64
+ const push = (value, prefix) => {
65
+ if (typeof value !== 'string') return;
66
+ if (!prefix) patterns.push(value);
67
+ else if (value.startsWith('!'))
68
+ patterns.push(`!${prefix}${value.slice(1)}`);
69
+ else patterns.push(`${prefix}${value}`);
70
+ };
71
+
72
+ const addAll = (entry, prefix) => {
73
+ if (!Array.isArray(entry)) return;
74
+ sawEntryArray = true;
75
+ for (const value of entry) push(value, prefix);
76
+ };
77
+
78
+ addAll(parsedConfig?.entry, '');
79
+
80
+ const workspaces = parsedConfig?.workspaces;
81
+ if (workspaces && typeof workspaces === 'object') {
82
+ for (const [name, workspace] of Object.entries(workspaces)) {
83
+ const trimmed = name.replace(/^\.\/+/, '').replace(/\/+$/, '');
84
+ const prefix = trimmed === '' || trimmed === '.' ? '' : `${trimmed}/`;
85
+ addAll(workspace?.entry, prefix);
86
+ }
87
+ }
88
+
89
+ return { patterns, sawEntryArray };
90
+ }
91
+
92
+ /**
93
+ * Resolve the entry patterns knip would see for `repoRoot`.
94
+ *
95
+ * Three outcomes, deliberately distinct — collapsing the first two is how a
96
+ * broken configuration would come to look like an absent one:
97
+ *
98
+ * skipped — nothing to check (no config file, no `package.json#knip`, or no
99
+ * resolvable `knip`). The gate exits 0. This is what makes it safe to wire
100
+ * into every consumer, matching `qa.gherkinLint`'s opt-in posture.
101
+ * error — a configuration exists but could not be resolved, or enumerates no
102
+ * entry points at all. The gate exits 2.
103
+ * resolved — `patterns` carries every declared entry, top-level and
104
+ * per-workspace, with knip's trailing `!` production markers intact.
105
+ *
106
+ * @param {{ repoRoot: string, loadKnipSession?: () => Promise<object> }} opts
107
+ * @returns {Promise<{
108
+ * patterns: string[],
109
+ * configFilePath: string | null,
110
+ * skipped: string | null,
111
+ * error: string | null,
112
+ * }>}
113
+ */
114
+ export async function resolveKnipEntryPatterns({
115
+ repoRoot,
116
+ loadKnipSession = importKnipSession,
117
+ }) {
118
+ const nothing = { patterns: [], configFilePath: null };
119
+
120
+ let createOptions;
121
+ try {
122
+ ({ createOptions } = await loadKnipSession());
123
+ } catch (error) {
124
+ return {
125
+ ...nothing,
126
+ skipped: `the "knip" package is not resolvable from ${repoRoot} (${error.message})`,
127
+ error: null,
128
+ };
129
+ }
130
+ if (typeof createOptions !== 'function') {
131
+ return {
132
+ ...nothing,
133
+ skipped: null,
134
+ error:
135
+ 'the installed "knip" does not export createOptions from "knip/session" — this gate needs knip 6 or newer',
136
+ };
137
+ }
138
+
139
+ let options;
140
+ try {
141
+ options = await createOptions({ cwd: repoRoot, args: {} });
142
+ } catch (error) {
143
+ return {
144
+ ...nothing,
145
+ skipped: null,
146
+ error: `cannot resolve the knip configuration: ${error.message}`,
147
+ };
148
+ }
149
+
150
+ const configFilePath = options?.configFilePath ?? null;
151
+ if (!configFilePath) {
152
+ return {
153
+ ...nothing,
154
+ skipped: `no knip configuration found under ${repoRoot} (no config file, no package.json#knip)`,
155
+ error: null,
156
+ };
157
+ }
158
+
159
+ const { patterns, sawEntryArray } = collectEntryPatterns(
160
+ options?.parsedConfig,
161
+ );
162
+ if (!sawEntryArray) {
163
+ return {
164
+ patterns: [],
165
+ configFilePath,
166
+ skipped: null,
167
+ error: `${configFilePath} declares no "entry" array, at the top level or in any workspace`,
168
+ };
169
+ }
170
+
171
+ return { patterns, configFilePath, skipped: null, error: null };
172
+ }
173
+
174
+ /**
175
+ * Test-only seam. Not API — `collectEntryPatterns` is exercised directly so the
176
+ * workspace-prefix and negation rules can be pinned without a fixture tree.
177
+ */
178
+ export const __testing = Object.freeze({
179
+ collectEntryPatterns,
180
+ importKnipSession,
181
+ });
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * knip-entry-sync.js — derive which top-level `.agents/scripts/*.js` CLIs are
3
- * actually invoked, and diff that set against `knip.json`'s `entry` array.
3
+ * actually invoked, and diff that set against the `entry` array of whatever
4
+ * configuration knip itself would load (Story #5039; #5026 could only read a
5
+ * literal `knip.json`).
4
6
  *
5
7
  * Why this exists (Story #5001 → the #5012 near-miss):
6
8
  *
@@ -20,7 +22,7 @@
20
22
  * real death later. It was caught only because a base-sync conflict forced a
21
23
  * manual read of the diff.
22
24
  *
23
- * The fix is to stop depending on a human remembering to edit `knip.json`.
25
+ * The fix is to stop depending on a human remembering to edit the config.
24
26
  * #5001's own wording already names the derivation rule — entries are derived
25
27
  * "from what package.json scripts, husky hooks, .github/workflows, and the
26
28
  * .agents workflow/skill markdown actually invoke". This module mechanizes
@@ -53,6 +55,10 @@
53
55
 
54
56
  import fs from 'node:fs';
55
57
  import path from 'node:path';
58
+ import {
59
+ resolveKnipEntryPatterns,
60
+ __testing as resolverTesting,
61
+ } from './knip-config-resolver.js';
56
62
 
57
63
  // Only `resolveEntrySync` and `renderEntrySyncReport` are public — they are
58
64
  // what `check-knip-entries.js` calls. Everything else is module-private and
@@ -261,9 +267,13 @@ function listTopLevelClis({ repoRoot, fsImpl = fs }) {
261
267
  }
262
268
 
263
269
  /**
264
- * Read the top-level `.agents/scripts/*.js` basenames declared in `knip.json`'s
265
- * `entry` array. Glob-bearing patterns are ignored — this reads the explicit
266
- * enumeration only, which is the surface #5001 made authoritative.
270
+ * Read the `.agents/scripts/*.js` basenames declared as knip entry points.
271
+ *
272
+ * Resolution goes through knip's own loader see
273
+ * `knip-config-resolver.js` for why, and for the skipped / error / resolved
274
+ * contract this forwards. All this adds is the projection onto top-level CLI
275
+ * basenames: glob-bearing patterns are dropped, so what remains is the explicit
276
+ * enumeration #5001 made authoritative.
267
277
  *
268
278
  * The trailing `!` is knip's production-mode marker and is load-bearing, not
269
279
  * decoration: in a `--production` run knip keeps only the suffixed entries and
@@ -272,45 +282,41 @@ function listTopLevelClis({ repoRoot, fsImpl = fs }) {
272
282
  * unreachable in exactly the pass that emits whole-file rows — the #5012 shape
273
283
  * this gate exists to catch. Reading it as satisfied would point the operator
274
284
  * away from the cause, so unsuffixed entries are reported as their own
275
- * divergence rather than silently normalized.
285
+ * divergence rather than silently normalized. `createOptions` preserves the
286
+ * suffix, so the marker survives resolution.
276
287
  *
277
- * @param {{ repoRoot: string, fsImpl?: typeof fs }} opts
278
- * @returns {{ entries: string[], unsuffixed: string[], error: string | null }}
288
+ * @param {{ repoRoot: string, loadKnipSession?: () => Promise<object> }} opts
289
+ * @returns {Promise<{
290
+ * entries: string[],
291
+ * unsuffixed: string[],
292
+ * configFilePath: string | null,
293
+ * skipped: string | null,
294
+ * error: string | null,
295
+ * }>}
279
296
  */
280
- function readKnipEntries({ repoRoot, fsImpl = fs }) {
281
- const configPath = path.join(repoRoot, 'knip.json');
282
- let parsed;
283
- try {
284
- parsed = JSON.parse(fsImpl.readFileSync(configPath, 'utf8'));
285
- } catch (error) {
286
- return {
287
- entries: [],
288
- unsuffixed: [],
289
- error: `cannot read knip.json: ${error.message}`,
290
- };
291
- }
292
- if (!Array.isArray(parsed?.entry)) {
293
- return {
294
- entries: [],
295
- unsuffixed: [],
296
- error: 'knip.json has no "entry" array',
297
- };
297
+ async function readKnipEntries({ repoRoot, loadKnipSession }) {
298
+ const { patterns, configFilePath, skipped, error } =
299
+ await resolveKnipEntryPatterns({ repoRoot, loadKnipSession });
300
+ if (skipped || error) {
301
+ return { entries: [], unsuffixed: [], configFilePath, skipped, error };
298
302
  }
303
+
299
304
  const prefix = '.agents/scripts/';
300
- const declared = parsed.entry
301
- .filter((e) => typeof e === 'string' && e.startsWith(prefix))
302
- .filter((e) => !e.includes('*'))
305
+ const declared = patterns
306
+ .filter((e) => e.startsWith(prefix) && !e.includes('*'))
303
307
  .map((e) => ({
304
308
  cli: e.slice(prefix.length).replace(/!$/, ''),
305
309
  suffixed: e.endsWith('!'),
306
310
  }))
307
311
  .filter((e) => !e.cli.includes('/'));
308
312
 
309
- const entries = declared.map((e) => e.cli);
310
- const unsuffixed = declared.filter((e) => !e.suffixed).map((e) => e.cli);
311
313
  return {
312
- entries: [...new Set(entries)].sort(),
313
- unsuffixed: [...new Set(unsuffixed)].sort(),
314
+ entries: [...new Set(declared.map((e) => e.cli))].sort(),
315
+ unsuffixed: [
316
+ ...new Set(declared.filter((e) => !e.suffixed).map((e) => e.cli)),
317
+ ].sort(),
318
+ configFilePath,
319
+ skipped: null,
314
320
  error: null,
315
321
  };
316
322
  }
@@ -318,16 +324,27 @@ function readKnipEntries({ repoRoot, fsImpl = fs }) {
318
324
  /**
319
325
  * Resolve the full entry-sync report for a repository.
320
326
  *
321
- * @param {{ repoRoot: string, fsImpl?: typeof fs }} opts
322
- * @returns {{
327
+ * Async because knip's config loader is: a `knip.config.ts` has to be
328
+ * *evaluated*, not read. `EntrySync` names entry-list synchronization, not
329
+ * synchronous I/O, so the name still describes what this returns.
330
+ *
331
+ * @param {{
332
+ * repoRoot: string,
333
+ * fsImpl?: typeof fs,
334
+ * loadKnipSession?: () => Promise<object>,
335
+ * }} opts
336
+ * @returns {Promise<{
323
337
  * error: string | null,
338
+ * skipped: string | null,
339
+ * configFilePath: string | null,
324
340
  * clis: string[],
325
341
  * declared: string[],
326
342
  * missing: Array<{ cli: string, invokers: string[] }>,
327
343
  * stale: string[],
328
344
  * phantom: string[],
329
345
  * unsuffixed: string[],
330
- * }}
346
+ * }>}
347
+ * `skipped` — nothing to check; the caller passes rather than failing.
331
348
  * `missing` — invoked but not declared (the #5012 bug).
332
349
  * `stale` — declared but invoked by nothing.
333
350
  * `phantom` — declared but no such file on disk (a rename left the list behind).
@@ -335,13 +352,21 @@ function readKnipEntries({ repoRoot, fsImpl = fs }) {
335
352
  * production pass negates rather than honours (the #5012 bug wearing a
336
353
  * declared entry).
337
354
  */
338
- export function resolveEntrySync({ repoRoot, fsImpl = fs }) {
355
+ export async function resolveEntrySync({
356
+ repoRoot,
357
+ fsImpl = fs,
358
+ loadKnipSession,
359
+ }) {
339
360
  const {
340
361
  entries: declared,
341
362
  unsuffixed,
363
+ configFilePath,
364
+ skipped,
342
365
  error,
343
- } = readKnipEntries({ repoRoot, fsImpl });
366
+ } = await readKnipEntries({ repoRoot, loadKnipSession });
344
367
  const empty = {
368
+ skipped: null,
369
+ configFilePath,
345
370
  clis: [],
346
371
  declared,
347
372
  missing: [],
@@ -349,6 +374,7 @@ export function resolveEntrySync({ repoRoot, fsImpl = fs }) {
349
374
  phantom: [],
350
375
  unsuffixed: [],
351
376
  };
377
+ if (skipped) return { error: null, ...empty, skipped };
352
378
  if (error) return { error, ...empty };
353
379
 
354
380
  const clis = listTopLevelClis({ repoRoot, fsImpl });
@@ -379,7 +405,17 @@ export function resolveEntrySync({ repoRoot, fsImpl = fs }) {
379
405
  const onDisk = new Set(clis);
380
406
  const phantom = declared.filter((cli) => !onDisk.has(cli));
381
407
 
382
- return { error: null, clis, declared, missing, stale, phantom, unsuffixed };
408
+ return {
409
+ error: null,
410
+ skipped: null,
411
+ configFilePath,
412
+ clis,
413
+ declared,
414
+ missing,
415
+ stale,
416
+ phantom,
417
+ unsuffixed,
418
+ };
383
419
  }
384
420
 
385
421
  /**
@@ -460,6 +496,9 @@ export function renderEntrySyncReport(report) {
460
496
  */
461
497
  export const __testing = Object.freeze({
462
498
  buildInvocationPatterns,
499
+ // Re-exported from `knip-config-resolver.js` so the suite reaches the whole
500
+ // gate through one seam rather than importing two modules to test one gate.
501
+ collectEntryPatterns: resolverTesting.collectEntryPatterns,
463
502
  collectInvocationSurfaces,
464
503
  INVOCATION_SURFACES,
465
504
  listTopLevelClis,
@@ -15,11 +15,10 @@
15
15
  */
16
16
 
17
17
  import posix from 'node:path/posix';
18
- import { resolveListValue } from '../../config/shared.js';
19
18
  import { gitSpawn } from '../../git-utils.js';
20
19
  import { validateTaskBodies } from '../task-body-validator.js';
21
20
  import { validateAndNormalizeTickets } from '../ticket-validator.js';
22
- import { DEFAULT_REGISTRY_PATTERNS } from '../ticket-validator-conflicts.js';
21
+ import { resolveConflictPolicy } from '../ticket-validator-conflicts.js';
23
22
  import { normalizeVerifyTiers } from '../verify-tier-repair.js';
24
23
 
25
24
  /**
@@ -176,30 +175,6 @@ export function makeDefaultFanOutCounter({ baseBranchRef, cwd, git } = {}) {
176
175
  };
177
176
  }
178
177
 
179
- /**
180
- * Resolve the cross-Story conflict-finding policy from `_config.planning`.
181
- */
182
- function resolveConflictPolicy(cfg) {
183
- const planning = cfg?.planning;
184
- const policy = {
185
- failOnSharedEditors: planning?.failOnSharedEditors === true,
186
- requireExplicitCrossStoryDeps:
187
- planning?.requireExplicitCrossStoryDeps === true,
188
- failOnRegistryConflicts: planning?.failOnRegistryConflicts === true,
189
- failOnLargeFanOut: planning?.failOnLargeFanOut === true,
190
- };
191
- if (Number.isFinite(planning?.largeFanOutThreshold)) {
192
- policy.largeFanOutThreshold = planning.largeFanOutThreshold;
193
- }
194
- if (planning?.crossCuttingRegistries !== undefined) {
195
- policy.registries = resolveListValue(
196
- DEFAULT_REGISTRY_PATTERNS,
197
- planning.crossCuttingRegistries,
198
- );
199
- }
200
- return policy;
201
- }
202
-
203
178
  /**
204
179
  * Resolve the ref the persist gates probe against.
205
180
  *
@@ -62,6 +62,11 @@ import {
62
62
  evaluateDraftReachability,
63
63
  renderReachabilityOrphans,
64
64
  } from '../plan-reachability.js';
65
+ import {
66
+ computeAssembledConflictFindings,
67
+ conflictFindingKey,
68
+ renderHardConflictError,
69
+ } from '../ticket-validator-conflicts.js';
65
70
  import { upsertStructuredComment } from '../ticketing.js';
66
71
  import {
67
72
  enforceFanOutGate,
@@ -376,6 +381,50 @@ function resolveEffectiveRoute({
376
381
  };
377
382
  }
378
383
 
384
+ /**
385
+ * Re-run the cross-Story conflict passes over the assembled bodies and route
386
+ * the result (Story #5045).
387
+ *
388
+ * `validateTickets` runs before assembly, over the raw payload, so until now
389
+ * plan-time conflict analysis judged an artifact that is not the one persist
390
+ * writes — and the passes that scan `body.acceptance` / `body.verify` were
391
+ * inert on the canonical top-level authoring shape as a result.
392
+ *
393
+ * Three outcomes, in order:
394
+ *
395
+ * 1. **Hard findings throw.** Policy upgrades (`planning.failOnSharedEditors`,
396
+ * `planning.requireExplicitCrossStoryDeps`) are off by default; when an
397
+ * operator turns one on it must bite on the persisted artifact too, and it
398
+ * must bite **before** the first `createIssue`.
399
+ * 2. **Soft findings the raw pass already reported are dropped**, so the same
400
+ * collision is not announced twice per run.
401
+ * 3. **Everything else is returned** for the plan-summary comment, which is
402
+ * where these findings stop being a stderr line nobody keeps.
403
+ *
404
+ * @param {{ stories: object[], config: object, rawFindings: object[] }} args
405
+ * @returns {object[]} The assembled-pass findings, for the summary comment.
406
+ */
407
+ function analyzeAssembledStories({ stories, config, rawFindings }) {
408
+ const findings = computeAssembledConflictFindings({ stories, config });
409
+ const hard = findings.filter((finding) => finding.severity === 'hard');
410
+ if (hard.length > 0) {
411
+ throw new Error(
412
+ `[plan-persist] ${hard.length} cross-Story conflict(s) in the assembled ` +
413
+ `Story bodies:\n${hard.map((f) => ` - ${renderHardConflictError(f)}`).join('\n')}`,
414
+ );
415
+ }
416
+ const alreadyReported = new Set(
417
+ (rawFindings ?? []).map((finding) => conflictFindingKey(finding)),
418
+ );
419
+ surfaceSoftConflictFindings(
420
+ findings.filter(
421
+ (finding) => !alreadyReported.has(conflictFindingKey(finding)),
422
+ ),
423
+ 'plan-persist/assembled',
424
+ );
425
+ return findings;
426
+ }
427
+
379
428
  /**
380
429
  * Reap abandoned `plan-*` directories under the temp root (Story #4541).
381
430
  *
@@ -702,14 +751,25 @@ export async function runPlanPersist({
702
751
  sharedSpec: techSpecContent,
703
752
  planAcceptance: planAcceptance ?? undefined,
704
753
  sourceTicketIds,
705
- // The seed this plan was authored from is the provenance source: an audit
706
- // sweep's Single-plan seed carries the `audit-fingerprints` /
707
- // `audit-semantic-keys` footers, which assembly copies into every persisted
708
- // Story body so the next sweep recognises what it already planned
709
- // (Story #4877). Empty for a `--tickets` run, which is a no-op.
754
+ // The seed this plan was authored from: an audit sweep's Single-plan seed
755
+ // carries the `audit-fingerprints` / `audit-semantic-keys` footers, and
756
+ // assembly copies them into the persisted Story bodies so the next sweep
757
+ // recognises what it already planned (Story #4877). Since Story #5045 this
758
+ // is the **fallback** it is carried onto every Story that did not
759
+ // attribute its own `provenance`, which keeps an un-attributed plan exactly
760
+ // as recall-safe as it was. Empty for a `--tickets` run, a no-op there.
710
761
  provenanceSource: planContextEnvelope?.seed?.content ?? '',
711
762
  });
712
763
 
764
+ // Story #5045: the cross-Story conflict passes re-run over the assembled,
765
+ // footer-stamped bodies — the artifact persist actually writes — before any
766
+ // GitHub call, so a policy upgrade still refuses the plan pre-creation.
767
+ const assembledConflicts = analyzeAssembledStories({
768
+ stories,
769
+ config,
770
+ rawFindings: validated.findings,
771
+ });
772
+
713
773
  // Effective complexity route (Story #4722): the planner's authored lite
714
774
  // verdict (recorded reason), validated against every assembled Story's own
715
775
  // shape — a claim exceeding the shape ceilings fails closed to full. Lite
@@ -764,6 +824,10 @@ export async function runPlanPersist({
764
824
  mode: 'stories',
765
825
  planMetricsLine,
766
826
  stories: created,
827
+ // Story #5045: the wave table promises what can run in parallel; the
828
+ // collisions the conflict passes found belong on the same surface, or the
829
+ // promise is the only half anyone reads.
830
+ conflictFindings: assembledConflicts,
767
831
  });
768
832
 
769
833
  if (!dryRun) {