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
@@ -17,6 +17,10 @@
17
17
 
18
18
  import { createHash } from 'node:crypto';
19
19
  import { applyBlockedByDependencies } from '../../../providers/github/blocked-by-add.js';
20
+ import {
21
+ normalizeOwnedProvenance,
22
+ ownedProvenanceSource,
23
+ } from '../../findings/provenance-field.js';
20
24
  import { carryProvenanceFooters } from '../../findings/route-finding.js';
21
25
  import { Logger } from '../../Logger.js';
22
26
  import { AGENT_LABELS, TYPE_LABELS } from '../../label-constants.js';
@@ -319,8 +323,12 @@ function syncContractFieldFromTopLevel(ticket, bodyObject, field) {
319
323
  * bookkeeping for the `--tickets` source issues, not part of the Story's
320
324
  * executable body, so it is deliberately not serialized into the markdown.
321
325
  *
326
+ * `provenance` is likewise top-level-only (Story #5045) — it names the audit
327
+ * identities *this* Story owns, and is stamped into the body as footers rather
328
+ * than serialized as a section.
329
+ *
322
330
  * @param {object} ticket
323
- * @returns {{ slug: string, title: string, bodyObject: object, depends_on: string[], labels: string[], supersedes: Array<{ id: number, note: string|null }> }}
331
+ * @returns {{ slug: string, title: string, bodyObject: object, depends_on: string[], labels: string[], supersedes: Array<{ id: number, note: string|null }>, provenance: { fingerprints: string[], semanticKeys: string[] }|null }}
324
332
  */
325
333
  export function normalizeStoryTicket(ticket) {
326
334
  if (!ticket || typeof ticket !== 'object') {
@@ -345,8 +353,22 @@ export function normalizeStoryTicket(ticket) {
345
353
  const depends_on = normalizeDependsOn(ticket, bodyObject);
346
354
  const supersedes = normalizeSupersedes(ticket, slug);
347
355
  const labels = sanitizeAuthoredLabels(ticket.labels, slug);
356
+ let provenance;
357
+ try {
358
+ provenance = normalizeOwnedProvenance(ticket.provenance, slug);
359
+ } catch (err) {
360
+ throw new Error(`[plan-persist] ${err.message}`);
361
+ }
348
362
 
349
- return { slug, title, bodyObject, depends_on, labels, supersedes };
363
+ return {
364
+ slug,
365
+ title,
366
+ bodyObject,
367
+ depends_on,
368
+ labels,
369
+ supersedes,
370
+ provenance,
371
+ };
350
372
  }
351
373
 
352
374
  /**
@@ -388,24 +410,59 @@ export function foldSpecIntoStoryBody(bodyObject, slug, opts = {}) {
388
410
  return { bodyObject: next };
389
411
  }
390
412
 
413
+ /**
414
+ * Resolve the provenance source one Story is stamped from (Story #5045).
415
+ *
416
+ * Two channels, and the precedence between them is the whole contract:
417
+ *
418
+ * - **Attributed** — the Story authored a `provenance` field naming the audit
419
+ * identities *it* owns. Exactly those are stamped. This is what makes the
420
+ * footers answer "which Story tracks this finding?" instead of "which sweep
421
+ * planned it?": under the union every sibling carried every key, so the next
422
+ * sweep's confirmation pass could only pick an arbitrary open Story, and a
423
+ * key whose owner had since closed was masked by any open neighbour.
424
+ * - **Union fallback** — no `provenance` field, so the whole seed's footers are
425
+ * carried, exactly as before. This is **not** dead weight to be tidied away:
426
+ * hand-carried provenance is the failure Stories #4626 / #4877 measured, and
427
+ * the union is what closed it. Attribution is additive and recall-safe;
428
+ * deleting the fallback would re-open that hole for every plan that does not
429
+ * attribute.
430
+ *
431
+ * @param {{ fingerprints: string[], semanticKeys: string[] }|null} provenance
432
+ * @param {object} opts Assembly options carrying `provenanceSource`.
433
+ * @returns {string}
434
+ */
435
+ function resolveProvenanceSource(provenance, opts) {
436
+ if (provenance !== null) return ownedProvenanceSource(provenance);
437
+ return opts.provenanceSource ?? '';
438
+ }
439
+
391
440
  function assembleOnePlanStory(ticket, opts) {
392
- const { slug, title, bodyObject, depends_on, labels, supersedes } =
393
- normalizeStoryTicket(ticket);
441
+ const {
442
+ slug,
443
+ title,
444
+ bodyObject,
445
+ depends_on,
446
+ labels,
447
+ supersedes,
448
+ provenance,
449
+ } = normalizeStoryTicket(ticket);
394
450
  const { bodyObject: folded } = foldSpecIntoStoryBody(bodyObject, slug, {
395
451
  sharedSpec: opts.sharedSpec ?? null,
396
452
  });
397
453
  // Body first: the fingerprint is an identity over the *assembled* content,
398
454
  // so it cannot be computed until that content exists.
399
455
  const serialized = serializeStoryBody({ ...folded, depends_on });
400
- // Carry audit dedup provenance out of the seed this plan was authored from
401
- // (Story #4877). The audit sweep's Single-plan path stamps the
402
- // `audit-fingerprints` / `audit-semantic-keys` footers into the seed it hands
403
- // `/plan`; without this the persisted Story carries no provenance and the
404
- // next sweep re-files work it already planned. Mechanical on purpose — the
405
- // authoring agent is not asked to notice HTML comments in a one-pager. A
406
- // non-audit seed carries no footers, so this is a no-op there.
456
+ // Carry audit dedup provenance into the persisted body (Story #4877). The
457
+ // audit sweep's Single-plan path stamps the `audit-fingerprints` /
458
+ // `audit-semantic-keys` footers into the seed it hands `/plan`; without this
459
+ // the persisted Story carries no provenance and the next sweep re-files work
460
+ // it already planned. Mechanical on purpose — the authoring agent is not
461
+ // asked to notice HTML comments in a one-pager. A non-audit seed carries no
462
+ // footers, so this is a no-op there. Which identities reach *this* Story is
463
+ // `resolveProvenanceSource`'s call (Story #5045).
407
464
  const { body } = carryProvenanceFooters({
408
- from: opts.provenanceSource ?? '',
465
+ from: resolveProvenanceSource(provenance, opts),
409
466
  into: serialized,
410
467
  });
411
468
  const fingerprint = planStoryFingerprint({ slug, title, body });
@@ -615,13 +672,30 @@ function renderStoryBodyForCreate(story, idBySlug) {
615
672
  const dependencyRefs = story.depends_on.map(
616
673
  (slug) => `#${idBySlug.get(slug)}`,
617
674
  );
618
- const base =
619
- dependencyRefs.length === 0
620
- ? story.body
621
- : serializeStoryBody(
622
- { ...story.bodyObject, depends_on: dependencyRefs },
623
- { includeFooter: true },
624
- );
675
+ let base = story.body;
676
+ if (dependencyRefs.length > 0) {
677
+ // Re-serializing from `bodyObject` is what resolves the sibling slugs to
678
+ // real issue ids — but `bodyObject` never held the provenance footers
679
+ // (`assembleOnePlanStory` appends those to the body *string*), so this
680
+ // branch drops them unless the carry is re-applied. That is the exact
681
+ // loss site Story #4935 diagnosed, #4939 fixed, and #4956 reverted
682
+ // wholesale hours later; Story #5056 restored it with a persist-side
683
+ // regression test that reads the POSTed body.
684
+ //
685
+ // `from: story.body` — not the seed — is load-bearing: it re-carries the
686
+ // identities *this* Story was stamped with under Story #5045 attribution
687
+ // rather than reintroducing the whole seed's union. `carryProvenanceFooters`
688
+ // is additive, union-preserving and idempotent, so re-applying is safe by
689
+ // construction.
690
+ const reserialized = serializeStoryBody(
691
+ { ...story.bodyObject, depends_on: dependencyRefs },
692
+ { includeFooter: true },
693
+ );
694
+ base = carryProvenanceFooters({
695
+ from: story.body,
696
+ into: reserialized,
697
+ }).body;
698
+ }
625
699
  return `${base}\n\n${planFingerprintMarker(story.fingerprint)}`;
626
700
  }
627
701
 
@@ -65,6 +65,53 @@ function renderWaveTableLines(waveTable) {
65
65
  return ['| Order | Stories |', '| --- | --- |', ...rows];
66
66
  }
67
67
 
68
+ /**
69
+ * Render the shared-editor collisions beside the wave table (Story #5045).
70
+ *
71
+ * The wave table is a promise about parallelism — "these Stories can run
72
+ * together". `computeSharedEditorFindings` knows exactly where that promise
73
+ * breaks down: a path two same-wave Stories both write will conflict on every
74
+ * merge after the first. Until now those findings degraded to a `Logger.warn`
75
+ * on stderr and were discarded, so the comment carried the optimistic half of
76
+ * the analysis and none of the caveat. Rendering them here puts the promise
77
+ * and its known exceptions on one durable surface.
78
+ *
79
+ * Advisory by default and labelled as such — `planning.failOnSharedEditors`
80
+ * is the knob that makes a collision refuse the plan, and it stays off.
81
+ *
82
+ * @param {object[]|null} conflictFindings
83
+ * @returns {string[]} Lines to splice after the wave table, or `[]`.
84
+ */
85
+ function renderSharedEditorLines(conflictFindings) {
86
+ const shared = (
87
+ Array.isArray(conflictFindings) ? conflictFindings : []
88
+ ).filter((finding) => finding?.kind === 'shared-editor');
89
+ if (shared.length === 0) return [];
90
+ const rows = shared
91
+ .slice()
92
+ .sort((a, b) => String(a.path).localeCompare(String(b.path)))
93
+ .map(
94
+ (finding) =>
95
+ `| \`${finding.path}\` | ${(finding.storySlugs ?? [])
96
+ .map((slug) => `\`${slug}\``)
97
+ .join(', ')} |`,
98
+ );
99
+ return [
100
+ '',
101
+ `#### ⚠️ Known collisions (${shared.length} shared file(s))`,
102
+ '',
103
+ '| Path | Stories in the same order |',
104
+ '| --- | --- |',
105
+ ...rows,
106
+ '',
107
+ '_These Stories are scheduled to run together **and** write the same ' +
108
+ 'file — expect a merge conflict on every landing after the first. Add a ' +
109
+ '`depends_on` edge to serialize them, or move the shared edit into one ' +
110
+ 'Story. Advisory: `planning.failOnSharedEditors` turns this into a ' +
111
+ 'refusal and is off by default._',
112
+ ];
113
+ }
114
+
68
115
  /**
69
116
  * Build the `plan-summary` structured-comment body.
70
117
  *
@@ -81,6 +128,7 @@ export function buildPlanSummaryCommentBody({
81
128
  mode = 'stories',
82
129
  planMetricsLine = null,
83
130
  stories = null,
131
+ conflictFindings = null,
84
132
  // legacy unused knobs kept so older test call sites don't crash mid-migration
85
133
  single = null,
86
134
  amend = null,
@@ -132,6 +180,7 @@ export function buildPlanSummaryCommentBody({
132
180
  '#### Delivery order (`depends_on`)',
133
181
  '',
134
182
  ...renderWaveTableLines(waveTable),
183
+ ...renderSharedEditorLines(conflictFindings),
135
184
  '',
136
185
  `_Deliver with \`${deliverCommand}\` — \`/deliver\` resolves the dependency graph from live state, so edges may point at Stories from earlier plan runs._`,
137
186
  ].join('\n');
@@ -182,9 +182,16 @@ export function storyFootprintPaths(body, id, warn) {
182
182
  }
183
183
 
184
184
  /**
185
- * Build the DAG nodes. `dependsOn` is the union the adjacency builder already
186
- * computes (body-parsed `blocked by #N` + explicit fields), plus any native
187
- * edges threaded in via `nativeEdges`. `files` is a plain `string[]`.
185
+ * Build the DAG nodes. `dependsOn` is the **union of the two declared-edge
186
+ * channels**: the Story body's `---` footer (`blocked by #N`) and the native
187
+ * GitHub `blocked_by` relations threaded in via `nativeEdges`. `files` is a
188
+ * plain `string[]`.
189
+ *
190
+ * The body channel is footer-scoped and strict (`parseBlockedBy`, Story
191
+ * #5046) — a `blocked by #123` mention in prose no longer mints a dispatch
192
+ * gate. Only `{ id, dependsOn }` is handed to the adjacency builder, never the
193
+ * body: the edge set is decided here, once, so the builder's own body parse
194
+ * cannot re-derive a different one behind this function's back.
188
195
  *
189
196
  * @param {object[]} stories
190
197
  * @param {Map<number, number[]>} [nativeEdges]
@@ -193,7 +200,7 @@ export function storyFootprintPaths(body, id, warn) {
193
200
  */
194
201
  export function storiesToDag(stories, nativeEdges = new Map(), warn) {
195
202
  const withNative = stories.map((s) => ({
196
- ...s,
203
+ id: s.id,
197
204
  dependsOn: [
198
205
  ...new Set([
199
206
  ...parseBlockedBy(s.body ?? ''),
@@ -222,28 +229,39 @@ export function storiesToDag(stories, nativeEdges = new Map(), warn) {
222
229
  * matching no Story, foreign to the set, never satisfiable, and (because
223
230
  * foreign edges are real gates) a silent permanent wedge.
224
231
  *
225
- * Cross-repo blockers are rejected rather than matched: another repo's #4530
226
- * is not this repo's #4530, and treating it as one could satisfy a gate that
227
- * is still open.
232
+ * A cross-repo blocker is **dropped with a loud warning**, never matched:
233
+ * another repo's #4530 is not this repo's #4530, and treating it as one could
234
+ * satisfy a gate that is still open. It used to throw, which failed the WHOLE
235
+ * resolution — one Story's unsupported edge took every sibling down with it
236
+ * (Story #5046). The degrade is now scoped to the Story carrying the edge:
237
+ * its siblings resolve normally, and the operator is told, by number, which
238
+ * Story lost which edge.
228
239
  *
229
240
  * @param {unknown} data Parsed API response.
230
- * @param {{ owner: string, repo: string, issueNumber: number }} ctx
241
+ * @param {{ owner: string, repo: string, issueNumber: number, warn?: (msg: string) => void }} ctx
231
242
  * @returns {number[]}
232
243
  */
233
- export function nativeBlockedByNumbers(data, { owner, repo, issueNumber }) {
244
+ export function nativeBlockedByNumbers(
245
+ data,
246
+ { owner, repo, issueNumber, warn },
247
+ ) {
234
248
  if (!Array.isArray(data)) return [];
235
249
  const out = [];
236
250
  for (const item of data) {
237
251
  const repoUrl = item?.repository_url ?? item?.repository?.url ?? null;
238
- if (typeof repoUrl === 'string' && repoUrl.length > 0) {
239
- const expected = `/repos/${owner}/${repo}`;
240
- if (!repoUrl.endsWith(expected)) {
241
- throw new Error(
242
- `[resolve-stories] #${issueNumber} is blocked by an issue in another repository ` +
243
- `(${repoUrl}). Cross-repo dependency edges are not supported — its number cannot ` +
244
- `be matched against this repo's Stories without risking a false match.`,
245
- );
246
- }
252
+ if (
253
+ typeof repoUrl === 'string' &&
254
+ repoUrl.length > 0 &&
255
+ !repoUrl.endsWith(`/repos/${owner}/${repo}`)
256
+ ) {
257
+ warn?.(
258
+ `[resolve-stories] #${issueNumber} declares a native blocked_by edge on an issue in ` +
259
+ `another repository (${repoUrl}). Cross-repo edges are not supported — its number ` +
260
+ `cannot be matched against this repo's Stories without risking a false match, so the ` +
261
+ `edge is DROPPED for #${issueNumber} only. Its siblings resolve normally; re-declare ` +
262
+ `the ordering in this repo if #${issueNumber} must wait.`,
263
+ );
264
+ continue;
247
265
  }
248
266
  const number = Number(item?.number);
249
267
  if (Number.isInteger(number) && number > 0) out.push(number);
@@ -252,17 +270,33 @@ export function nativeBlockedByNumbers(data, { owner, repo, issueNumber }) {
252
270
  }
253
271
 
254
272
  /**
255
- * Read an issue's native `blocked_by` edges as issue numbers.
273
+ * Read an issue's native `blocked_by` edges as issue numbers, **paginated to
274
+ * exhaustion**.
275
+ *
276
+ * The read used to take the first page only, so a Story with more than a
277
+ * page of blockers silently lost every edge past the boundary — the exact
278
+ * failure this function's fail-loud contract exists to prevent, arriving
279
+ * through the one door that never raised (Story #5046). `paginate` is
280
+ * injected (the CLI passes `paginateRest`) so the lib layer stays free of a
281
+ * provider import and the page walk stays testable without a live round-trip.
256
282
  *
257
- * **Fails loud**, deliberately inverting the write path's non-fatal contract.
258
- * A dropped write-side edge is cosmetic (the ordering still lives in the
259
- * `blocked by #N` body footer); a dropped READ-side edge silently removes a
260
- * dispatch gate, so a 403 (dependencies API disabled, or a token without the
261
- * scope) would erase every native edge at once and co-dispatch the whole run
262
- * against unlanded blockers. A 404 means "no dependencies on this issue" and
263
- * is a legitimate empty result.
283
+ * **Fails loud on every non-OK read**, deliberately inverting the write path's
284
+ * non-fatal contract. A dropped write-side edge is cosmetic (the ordering
285
+ * still lives in the `blocked by #N` body footer); a dropped READ-side edge
286
+ * silently removes a dispatch gate, so one failure would erase every native
287
+ * edge at once and co-dispatch the run against unlanded blockers.
264
288
  *
265
- * @param {{ gh: object, owner: string, repo: string, issueNumber: number, parseJson: Function }} opts
289
+ * **A 404 is not an empty result.** It used to be treated as "this issue has
290
+ * no dependencies", which is how GitHub answers an issue that genuinely has
291
+ * none — but it is *also* how GitHub answers a token that cannot see the
292
+ * dependencies API at all. Reading the second as the first erases every
293
+ * native edge in the run under a mis-scoped token, silently, with a clean
294
+ * exit code. An issue with no dependencies returns `200 []`, so the empty
295
+ * case needs no 404 escape hatch and the ambiguity resolves loud.
296
+ *
297
+ * @param {{ gh: object, owner: string, repo: string, issueNumber: number,
298
+ * paginate: (gh: object, endpoint: string, opts?: object) => Promise<unknown[]>,
299
+ * warn?: (msg: string) => void }} opts
266
300
  * @returns {Promise<number[]>}
267
301
  */
268
302
  export async function readNativeBlockedBy({
@@ -270,27 +304,30 @@ export async function readNativeBlockedBy({
270
304
  owner,
271
305
  repo,
272
306
  issueNumber,
273
- parseJson,
307
+ paginate,
308
+ warn,
274
309
  }) {
275
- let result;
310
+ const endpoint = `/repos/${owner}/${repo}/issues/${issueNumber}/dependencies/blocked_by`;
311
+ let items;
276
312
  try {
277
- result = await gh.api({
278
- method: 'GET',
279
- endpoint: `/repos/${owner}/${repo}/issues/${issueNumber}/dependencies/blocked_by`,
313
+ items = await paginate(gh, endpoint, {
314
+ label: `[resolve-stories] blocked_by #${issueNumber}`,
280
315
  });
281
316
  } catch (err) {
282
317
  const detail = String(err?.message ?? err);
283
- if (/404|not found/i.test(detail)) return [];
284
318
  throw new Error(
285
319
  `[resolve-stories] Could not read native blocked_by edges for #${issueNumber}: ${detail}. ` +
286
320
  `Refusing to continue: a dropped dependency edge would silently remove a dispatch gate ` +
287
- `and co-dispatch this Story against an unlanded blocker.`,
321
+ `and co-dispatch this Story against an unlanded blocker. A 404 here is NOT "no ` +
322
+ `dependencies" (that answers 200 with an empty list) — check the token's scopes and ` +
323
+ `that the dependencies API is enabled for ${owner}/${repo}.`,
288
324
  );
289
325
  }
290
- return nativeBlockedByNumbers(parseJson(result), {
326
+ return nativeBlockedByNumbers(items, {
291
327
  owner,
292
328
  repo,
293
329
  issueNumber,
330
+ warn,
294
331
  });
295
332
  }
296
333
 
@@ -1,3 +1,4 @@
1
+ import { resolveListValue } from '../config/shared.js';
1
2
  import { parse as parseStoryBody } from '../story-body/story-body.js';
2
3
  import { collectStoryAssumptionEntries } from './file-assumptions.js';
3
4
  import { computeStoryReachability } from './story-reachability.js';
@@ -108,8 +109,11 @@ const DEFAULT_POLICY = Object.freeze({
108
109
  * Patterns support two shapes:
109
110
  * - exact path — `lib/orchestration/lifecycle/listeners/index.js`
110
111
  * - `**` suffix — `**\/listeners/index.js` (matches any depth)
112
+ *
113
+ * Module-private since `resolveConflictPolicy` became the one production
114
+ * reader; tests reach it through `_internal`.
111
115
  */
112
- export const DEFAULT_REGISTRY_PATTERNS = Object.freeze([
116
+ const DEFAULT_REGISTRY_PATTERNS = Object.freeze([
113
117
  'lib/orchestration/lifecycle/listeners/index.js',
114
118
  '**/listeners/index.js',
115
119
  '**/handlers/index.js',
@@ -762,6 +766,117 @@ export function computeConflictFindings({ stories, policy } = {}) {
762
766
  ];
763
767
  }
764
768
 
769
+ /**
770
+ * Resolve the config-derived half of the conflict policy from
771
+ * `config.planning` — the severity flags, the fan-out threshold, and the
772
+ * registry patterns, in one place.
773
+ *
774
+ * Two passes consume `planning.*` and must not disagree: the raw
775
+ * pre-assembly pass (`persist-helpers.validateTickets`, which attaches its
776
+ * production `fanOutCounter` on top of this) and the post-assembly pass
777
+ * (`computeAssembledConflictFindings`, which forces `fanOutCounter: null`).
778
+ * Under Story #5045 each resolved its own copy, and the copies had already
779
+ * drifted — `failOnMissingBddScaffold` reached only the assembled pass,
780
+ * `failOnLargeFanOut` / `largeFanOutThreshold` / `crossCuttingRegistries`
781
+ * only the raw one — so a knob set in config silently applied on one of the
782
+ * two passes. A knob read here reaches both; that is the contract.
783
+ *
784
+ * `fanOutCounter` is deliberately absent: it is probe machinery, not
785
+ * config, and each caller owns its own.
786
+ *
787
+ * @param {object} [config] Resolved config carrying `planning.*`.
788
+ * @returns {object} A `computeConflictFindings` policy (no `fanOutCounter`).
789
+ */
790
+ export function resolveConflictPolicy(config) {
791
+ const planning = config?.planning;
792
+ const policy = {
793
+ failOnSharedEditors: planning?.failOnSharedEditors === true,
794
+ requireExplicitCrossStoryDeps:
795
+ planning?.requireExplicitCrossStoryDeps === true,
796
+ failOnRegistryConflicts: planning?.failOnRegistryConflicts === true,
797
+ failOnLargeFanOut: planning?.failOnLargeFanOut === true,
798
+ failOnMissingBddScaffold: planning?.failOnMissingBddScaffold === true,
799
+ };
800
+ if (Number.isFinite(planning?.largeFanOutThreshold)) {
801
+ policy.largeFanOutThreshold = planning.largeFanOutThreshold;
802
+ }
803
+ if (planning?.crossCuttingRegistries !== undefined) {
804
+ policy.registries = resolveListValue(
805
+ DEFAULT_REGISTRY_PATTERNS,
806
+ planning.crossCuttingRegistries,
807
+ );
808
+ }
809
+ return policy;
810
+ }
811
+
812
+ /**
813
+ * Re-run the cross-Story conflict passes over the **assembled** Story bodies —
814
+ * the artifact persist actually writes (Story #5045).
815
+ *
816
+ * `validateTickets` runs before `assemblePlanStories`, over the raw
817
+ * `stories.json` payload, so plan-time conflict analysis never saw what got
818
+ * persisted. That is not a cosmetic ordering nit: the canonical authoring shape
819
+ * carries `acceptance[]` / `verify[]` at the ticket's **top level**, and it is
820
+ * assembly's `syncContractFieldFromTopLevel` that folds them into the body.
821
+ * `indexConsumers` scans `body.acceptance` / `body.verify` for producer paths —
822
+ * so on the real payload it scanned two empty arrays, and every
823
+ * `implicit-cross-story-dep` and `missing-bdd-scaffold` finding was silently
824
+ * unreachable. Running the passes again over the serialized bodies restores
825
+ * them.
826
+ *
827
+ * **The fan-out pass is deliberately not re-run.** It is a `git grep` per
828
+ * deleted path and its inputs (`changes[]` deletes) are identical on both
829
+ * sides, so re-probing would double the git cost for a byte-identical answer;
830
+ * `enforceFanOutGate` already owns that class over the raw payload.
831
+ *
832
+ * @param {object} args
833
+ * @param {Array<{ slug: string, title?: string, body: string, depends_on?: string[] }>} args.stories
834
+ * Assembled Stories — `body` is the serialized, footer-stamped markdown.
835
+ * @param {object} [args.config] Resolved config; `planning.*` supplies the
836
+ * policy via {@link resolveConflictPolicy} — the same resolver the raw
837
+ * pass uses, so a knob cannot apply on only one of the two passes.
838
+ * @returns {ConflictFinding[]}
839
+ */
840
+ export function computeAssembledConflictFindings({ stories, config } = {}) {
841
+ return computeConflictFindings({
842
+ stories: (Array.isArray(stories) ? stories : []).map((story) => ({
843
+ slug: story.slug,
844
+ title: story.title,
845
+ body: story.body,
846
+ depends_on: Array.isArray(story.depends_on) ? story.depends_on : [],
847
+ })),
848
+ policy: {
849
+ ...resolveConflictPolicy(config),
850
+ fanOutCounter: null,
851
+ },
852
+ });
853
+ }
854
+
855
+ /**
856
+ * Stable identity for one conflict finding, so the post-assembly pass can be
857
+ * diffed against the raw pass and only the genuinely-new findings reported
858
+ * (Story #5045). Without it the two passes announce the same shared-editor
859
+ * collision twice per run, which is how a warning channel gets discounted.
860
+ *
861
+ * The separator is written as the `\u0000` escape and never as a raw byte — a
862
+ * literal NUL would make git classify this file as binary and drop its diffs.
863
+ *
864
+ * @param {object} finding
865
+ * @returns {string}
866
+ */
867
+ export function conflictFindingKey(finding) {
868
+ return [
869
+ finding?.kind ?? '',
870
+ finding?.path ?? finding?.registryPath ?? '',
871
+ Array.isArray(finding?.storySlugs)
872
+ ? [...finding.storySlugs].sort().join(',')
873
+ : (finding?.storySlug ?? ''),
874
+ finding?.producer?.storySlug ?? '',
875
+ finding?.consumer?.storySlug ?? '',
876
+ finding?.consumer?.sourceField ?? '',
877
+ ].join('\u0000');
878
+ }
879
+
765
880
  /**
766
881
  * Render the audit trail behind a fan-out finding's number, so an operator
767
882
  * can check the figure rather than trust it (Story #4547).
@@ -1,4 +1,5 @@
1
1
  import { ValidationError } from '../errors/index.js';
2
+ import { normalizeOwnedProvenance } from '../findings/provenance-field.js';
2
3
  import { detectCycle } from '../Graph.js';
3
4
  import { gitSpawn } from '../git-utils.js';
4
5
 
@@ -528,6 +529,41 @@ function assertEveryStoryHasInlineContract({ stories }) {
528
529
  );
529
530
  }
530
531
 
532
+ /**
533
+ * Shape-check the optional per-Story `provenance` field (Story #5045).
534
+ *
535
+ * The field decides which audit identities persist stamps into a Story body,
536
+ * so a malformed entry has to fail at the validator rather than at the
537
+ * stamper: by the time assembly runs, an unnoticed drop is indistinguishable
538
+ * from a Story that legitimately owns nothing — and the cost lands a whole
539
+ * sweep later, when the next audit re-files work this plan already tracked.
540
+ *
541
+ * Absence is valid and common: a Story with no `provenance` inherits the
542
+ * whole-seed union carry, which is the recall-safe default.
543
+ *
544
+ * Errors are batched across the backlog so one pass names every offender.
545
+ *
546
+ * @param {{ stories: object[] }} args
547
+ * @throws {Error} naming each malformed field.
548
+ */
549
+ function assertStoryProvenanceShape({ stories }) {
550
+ const violations = [];
551
+ for (const story of stories) {
552
+ try {
553
+ normalizeOwnedProvenance(story?.provenance, story?.slug ?? '<unknown>');
554
+ } catch (err) {
555
+ violations.push(` - ${err.message}`);
556
+ }
557
+ }
558
+ if (violations.length === 0) return;
559
+ throw new Error(
560
+ `Cross-Validation Failed: ${violations.length} Story provenance field(s) ` +
561
+ `are malformed:\n${violations.join('\n')}\n\nAuthor provenance as ` +
562
+ '{ "fingerprints": ["<40-char sha1>"], "semanticKeys": ["<area␟path>"] }, ' +
563
+ 'or omit it entirely to inherit the seed-wide union.',
564
+ );
565
+ }
566
+
531
567
  function assertNoUnknownDeps({ tickets, ticketBySlug }) {
532
568
  const unknownDeps = [];
533
569
  for (const t of tickets) {
@@ -575,6 +611,7 @@ export function validateAndNormalizeTickets(tickets, opts = {}) {
575
611
 
576
612
  assertAllTicketsAreStories({ tickets, stories });
577
613
  assertEveryStoryHasInlineContract({ stories });
614
+ assertStoryProvenanceShape({ stories });
578
615
  assertNoUnknownDeps({ tickets, ticketBySlug });
579
616
 
580
617
  assertAcyclic(slugAdjacency);
@@ -704,6 +741,7 @@ export const _internal = {
704
741
  indexTicketsBySlug,
705
742
  assertAllTicketsAreStories,
706
743
  assertEveryStoryHasInlineContract,
744
+ assertStoryProvenanceShape,
707
745
  assertNoUnknownDeps,
708
746
  assertAcyclic,
709
747
  attachFindingsAndErrors,