mandrel 2.32.0 → 2.33.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 (38) 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 +1 -0
  4. package/.agents/runtime-deps.json +2 -1
  5. package/.agents/schemas/agentrc.schema.json +6 -0
  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/lib/audit-to-stories/build-story-body.js +81 -34
  11. package/.agents/scripts/lib/audit-to-stories/wire-dependencies.js +185 -0
  12. package/.agents/scripts/lib/config/runners.js +38 -16
  13. package/.agents/scripts/lib/config-settings-schema-delivery.js +10 -2
  14. package/.agents/scripts/lib/dependency-parser.js +20 -7
  15. package/.agents/scripts/lib/findings/provenance-field.js +135 -0
  16. package/.agents/scripts/lib/findings/route-finding.js +57 -8
  17. package/.agents/scripts/lib/knip-config-resolver.js +181 -0
  18. package/.agents/scripts/lib/knip-entry-sync.js +78 -39
  19. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +1 -26
  20. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +69 -5
  21. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +69 -12
  22. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +49 -0
  23. package/.agents/scripts/lib/orchestration/resolve-stories.js +72 -35
  24. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +116 -1
  25. package/.agents/scripts/lib/orchestration/ticket-validator.js +38 -0
  26. package/.agents/scripts/lib/story-body/footer-block.js +97 -0
  27. package/.agents/scripts/lib/story-body/story-body.js +6 -22
  28. package/.agents/scripts/lib/wave-runner/footprint.js +306 -0
  29. package/.agents/scripts/lib/wave-runner/ready-set.js +198 -181
  30. package/.agents/scripts/providers/github/blocked-by-add.js +25 -10
  31. package/.agents/scripts/resolve-stories.js +21 -5
  32. package/.agents/scripts/stories-wave-tick.js +192 -9
  33. package/.agents/workflows/audit-to-stories.md +26 -0
  34. package/.agents/workflows/helpers/deliver-reference.md +28 -1
  35. package/.agents/workflows/helpers/deliver-story-reference.md +57 -0
  36. package/.agents/workflows/helpers/plan-reference.md +76 -0
  37. package/docs/CHANGELOG.md +16 -0
  38. package/package.json +3 -3
@@ -22,6 +22,7 @@
22
22
  * structured contract.
23
23
  */
24
24
 
25
+ import path from 'node:path';
25
26
  import { AGENT_LABELS, RISK_LABELS, TYPE_LABELS } from '../label-constants.js';
26
27
  import { serialize } from '../story-body/story-body.js';
27
28
  import { definesAuditLabel } from './audit-label-taxonomy.js';
@@ -116,10 +117,7 @@ function acceptanceCriteriaFromGroup(group) {
116
117
 
117
118
  /**
118
119
  * Resolve the `edges[]` sequencing anchored on this group. Each edge whose
119
- * `fromGroupKey` matches this group's key contributes its `toGroupKey`. Group
120
- * keys are the only stable identifier available at emit time — issues are not
121
- * numbered yet — so the relationship is preserved as machine-readable keys the
122
- * operator can resolve.
120
+ * `fromGroupKey` matches this group's key contributes its `toGroupKey`.
123
121
  *
124
122
  * @param {object} group
125
123
  * @param {Array<{ fromGroupKey: string, toGroupKey: string }>} edges
@@ -135,22 +133,38 @@ function sequencingDepsForGroup(group, edges) {
135
133
  }
136
134
 
137
135
  /**
138
- * Render the carried-through `edges[]` sequencing as a dedicated extended
139
- * markdown block. The canonical `depends_on[]` footer only round-trips `#N`
140
- * issue refs (`blocked by #123`), which do not exist before the issues are
141
- * opened; rendering the group-key sequencing as its own informational section
142
- * keeps the signal in the body (not discarded — Story #4270) and survives
143
- * `parse()` / `serialize()` round-tripping (it is preamble/extended content,
144
- * not a structured section). Returns the empty string when there is no
145
- * sequencing to surface.
136
+ * Resolve this group's sequencing to canonical `#N` issue refs, or `[]` when
137
+ * the caller has no issue numbers yet (Story #5044).
146
138
  *
147
- * @param {string[]} deps
148
- * @returns {string}
139
+ * Group keys are the only identifier that exists at *emit* time — the issues
140
+ * are not numbered — which is why this used to render as a prose
141
+ * `## Sequencing` block that nothing could act on. Standalone audit Stories
142
+ * therefore hardcoded `depends_on: []`, and their only actual serializer was
143
+ * an accident: their shared provenance footers collided under the delivery
144
+ * footprint guard. Narrowing that scrape removes the accident, so the ordering
145
+ * has to become real in the same change.
146
+ *
147
+ * The resolution is the two-pass shape `plan-persist` already uses: create every
148
+ * issue first, then re-render each body with the now-known numbers and mirror
149
+ * the same edges as native `blocked_by` relations. An edge whose target was not
150
+ * created (deduped against an existing Issue, suppressed by the ledger) simply
151
+ * drops — a `blocked by #undefined` would be worse than an absent edge.
152
+ *
153
+ * A **plain object**, deliberately, not a `Map`: the same map is handed to
154
+ * `applyBlockedByDependencies`, which indexes it with property access, so a
155
+ * `Map` there would silently resolve every lookup to `undefined`, skip every
156
+ * edge, and report success having written nothing. One shape, both halves.
157
+ *
158
+ * @param {string[]} deps Group keys this group depends on.
159
+ * @param {Record<string, number>|null} issueByGroupKey
160
+ * @returns {string[]} `#N` refs, in `deps` order.
149
161
  */
150
- function sequencingSection(deps) {
151
- if (deps.length === 0) return '';
152
- const lines = deps.map((k) => `- depends on group \`${k}\``);
153
- return ['## Sequencing', '', lines.join('\n'), ''].join('\n');
162
+ function dependencyRefs(deps, issueByGroupKey) {
163
+ if (!issueByGroupKey) return [];
164
+ return deps
165
+ .map((key) => issueByGroupKey[key])
166
+ .filter((n) => Number.isInteger(n) && n > 0)
167
+ .map((n) => `#${n}`);
154
168
  }
155
169
 
156
170
  function agentPromptsSection(group) {
@@ -162,6 +176,21 @@ function agentPromptsSection(group) {
162
176
  return blocks.join('\n\n') || '_(no copy-pasteable prompts captured)_';
163
177
  }
164
178
 
179
+ /**
180
+ * Link each source audit report **once**.
181
+ *
182
+ * This used to render `- [\`path\`](path)` — the same
183
+ * `temp/audits/audit-<lens>-results.md` in the link text and again in the URL,
184
+ * byte-identical across every Story of a same-lens sweep. That doubled a token
185
+ * the delivery footprint guard scraped as edit intent, so a lens's whole cohort
186
+ * serialized on a report none of them would ever write to (Story #5044). The
187
+ * guard now ignores markdown-link URLs and temp-root paths, but rendering the
188
+ * path twice was never useful to a reader either: the file name is the label,
189
+ * the path is the target.
190
+ *
191
+ * @param {object} group
192
+ * @returns {string}
193
+ */
165
194
  function contextLinksFromGroup(group) {
166
195
  const reports = uniq(
167
196
  (group.findings ?? [])
@@ -169,7 +198,11 @@ function contextLinksFromGroup(group) {
169
198
  .filter((s) => typeof s === 'string'),
170
199
  );
171
200
  if (reports.length === 0) return '_(no source audit reports captured)_';
172
- return reports.map((r) => `- [\`${r}\`](${r})`).join('\n');
201
+ // `path.basename` rather than `split('/')`: on win32 it splits on both
202
+ // separators, so an absolute Windows path yields the file name instead of
203
+ // the whole path — which would render the path twice in one link and
204
+ // re-create the very duplication this function exists to remove.
205
+ return reports.map((r) => `- [${path.basename(r)}](${r})`).join('\n');
173
206
  }
174
207
 
175
208
  function labelsForGroup(group) {
@@ -226,20 +259,27 @@ function assertLabelsInTaxonomy(labels) {
226
259
  * — the dependency `edges[]` emitted by `groupFindings`. Edges anchored on
227
260
  * this group are carried through to `depends_on[]`; omit when no sequencing
228
261
  * is known.
229
- * @returns {{ title: string, body: string, labels: string[] }}
262
+ * @param {Record<string, number>|null} [params.issueByGroupKey]
263
+ * — group key → opened issue number. Supplied on the **second** pass, once
264
+ * the issues exist, so this group's edges render as canonical
265
+ * `blocked by #N` footers (Story #5044). Omit on the first pass.
266
+ * @returns {{ title: string, body: string, labels: string[], groupKey: string, dependsOn: string[] }}
267
+ * `groupKey` and `dependsOn` are the caller's handle on the second pass:
268
+ * they name this Story and the groups it must follow, so the caller can map
269
+ * both onto issue numbers without re-deriving the grouping.
230
270
  */
231
- export function buildStoryBody({ group, edges = [] }) {
271
+ export function buildStoryBody({ group, edges = [], issueByGroupKey = null }) {
232
272
  if (!group || !Array.isArray(group.findings)) {
233
273
  throw new Error('buildStoryBody: group with findings[] is required');
234
274
  }
235
275
  const title = group.title;
276
+ const dependsOn = sequencingDepsForGroup(group, edges);
236
277
 
237
278
  // Build the canonical StoryBody object from the audit group data. The
238
279
  // acceptance + verify arrays are populated so the body clears the
239
- // inline-contract bar; changes[] carries the file footprint. The edges[]
240
- // sequencing is carried through as an extended `## Sequencing` block (see
241
- // sequencingSection) group keys are not `#N` refs, so they cannot ride the
242
- // canonical depends_on footer.
280
+ // inline-contract bar; changes[] carries the file footprint. `depends_on`
281
+ // is empty on the first pass (the blockers have no issue numbers yet) and
282
+ // carries real `#N` refs on the second see dependencyRefs.
243
283
  const storyBody = {
244
284
  goal: goalFromGroup(group),
245
285
  changes: changesFromGroup(group),
@@ -248,20 +288,21 @@ export function buildStoryBody({ group, edges = [] }) {
248
288
  references: [],
249
289
  wide: null,
250
290
  reason_to_exist: null,
251
- depends_on: [],
291
+ depends_on: dependencyRefs(dependsOn, issueByGroupKey),
252
292
  };
253
293
 
254
- // Serialize via the canonical serializer (no footer depends_on is empty).
255
- const canonicalSections = serialize(storyBody);
256
- const sequencing = sequencingSection(sequencingDepsForGroup(group, edges));
294
+ // The `---` / `blocked by #N` footer is the canonical serializer's own, so
295
+ // the body round-trips through `parse()` and `/deliver`'s resolver reads the
296
+ // ordering from the same place it reads every other Story's.
297
+ const canonicalSections = serialize(storyBody, {
298
+ includeFooter: storyBody.depends_on.length > 0,
299
+ });
257
300
 
258
- // Append audit-specific extended sections (sequencing, agent prompts,
259
- // context links, fingerprint footer) that are not part of the canonical
260
- // shape.
301
+ // Append audit-specific extended sections (agent prompts, context links,
302
+ // provenance footers) that are not part of the canonical shape.
261
303
  const body = [
262
304
  canonicalSections,
263
305
  '',
264
- ...(sequencing ? [sequencing] : []),
265
306
  '## Agent Prompts',
266
307
  '',
267
308
  agentPromptsSection(group),
@@ -276,5 +317,11 @@ export function buildStoryBody({ group, edges = [] }) {
276
317
  renderSemanticKeyFooter(group.findings),
277
318
  ].join('\n');
278
319
 
279
- return { title, body, labels: labelsForGroup(group) };
320
+ return {
321
+ title,
322
+ body,
323
+ labels: labelsForGroup(group),
324
+ groupKey: group.groupKey,
325
+ dependsOn,
326
+ };
280
327
  }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * lib/audit-to-stories/wire-dependencies.js — turn a standalone audit cohort's
3
+ * detected group edges into declared ordering, once the issues exist.
4
+ *
5
+ * `groupFindings` detects `edges[]` between finding groups, but at emit time
6
+ * the groups have no issue numbers, so the ordering could only be rendered as
7
+ * prose. Every standalone audit Story therefore shipped with `depends_on: []`,
8
+ * and what actually kept a cohort from being co-dispatched onto colliding
9
+ * branches was an **accident**: siblings shared the sweep-wide audit provenance
10
+ * footers `plan-persist` stamps, and the delivery footprint guard scraped
11
+ * path-shaped tokens out of them. Story #5044 narrows that scrape, which is why
12
+ * this module lands with it — removing the accidental serializer without giving
13
+ * the cohort a real one would leave it less ordered than before.
14
+ *
15
+ * The shape is `plan-persist`'s two-pass crossing (`plan-persist/story-ops.js`),
16
+ * because it is the same problem: **create every issue first, then mirror the
17
+ * edges**. Both halves are written:
18
+ *
19
+ * 1. The **body footer** (`---` / `blocked by #N`) — canonical, parsed by
20
+ * `/deliver`'s resolver, and the fallback when the dependencies API is
21
+ * unavailable.
22
+ * 2. The **native `blocked_by` relation** — visible in the GitHub UI,
23
+ * readable without parsing markdown, and settable by an operator later.
24
+ *
25
+ * Native mirroring is **non-fatal by design**, matching `plan-persist`: the
26
+ * footer has already been written by the time it runs, so a dependencies API
27
+ * that says no costs visibility, not ordering.
28
+ *
29
+ * @module lib/audit-to-stories/wire-dependencies
30
+ */
31
+
32
+ import { applyBlockedByDependencies } from '../../providers/github/blocked-by-add.js';
33
+ import { Logger } from '../Logger.js';
34
+ import { buildStoryBody } from './build-story-body.js';
35
+
36
+ /**
37
+ * Re-render each created Story's body with its blockers resolved to `#N`, and
38
+ * mirror the same edges as native `blocked_by` relations.
39
+ *
40
+ * Groups whose issue was not created — deduped against an existing Issue,
41
+ * suppressed by the ledger, or simply not in `issueByGroupKey` — are skipped
42
+ * rather than guessed at, and an edge pointing at one drops with them
43
+ * (`dependencyRefs` filters it). A `blocked by #undefined` would gate a Story
44
+ * on nothing forever, which is strictly worse than the un-ordered cohort this
45
+ * replaces.
46
+ *
47
+ * @param {object} args
48
+ * @param {Array<object>} args.groups The `create`-eligible groups, in
49
+ * the order their issues were opened.
50
+ * @param {Array<{ fromGroupKey: string, toGroupKey: string }>} [args.edges]
51
+ * @param {Record<string, number>} args.issueByGroupKey Group key → issue number.
52
+ * @param {(issueNumber: number, body: string) => Promise<unknown>} args.updateBody
53
+ * Persist a re-rendered body. Injected so the caller owns the provider call.
54
+ * @param {object|null} [args.provider] Provider for native edge mirroring.
55
+ * Omit (or pass one without the dependency ports) to write footers only.
56
+ * @returns {Promise<{
57
+ * storiesWired: number,
58
+ * bodiesUpdated: number,
59
+ * edgesDeclared: number,
60
+ * native: { edgesAdded: number, edgesSkipped: number, edgesFailed: number }|null
61
+ * }>}
62
+ */
63
+ export async function wireAuditStoryEdges({
64
+ groups,
65
+ edges = [],
66
+ issueByGroupKey,
67
+ updateBody,
68
+ provider = null,
69
+ }) {
70
+ const wired = collectWiredStories({ groups, edges, issueByGroupKey });
71
+ if (wired.length === 0) {
72
+ return {
73
+ storiesWired: 0,
74
+ bodiesUpdated: 0,
75
+ edgesDeclared: 0,
76
+ native: null,
77
+ };
78
+ }
79
+
80
+ let bodiesUpdated = 0;
81
+ for (const story of wired) {
82
+ await updateBody(story.issueNumber, story.body);
83
+ bodiesUpdated++;
84
+ }
85
+
86
+ return {
87
+ storiesWired: wired.length,
88
+ bodiesUpdated,
89
+ edgesDeclared: wired.reduce((n, s) => n + s.blockerKeys.length, 0),
90
+ native: await mirrorNativeEdges({ provider, wired, issueByGroupKey }),
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Re-render every group that both has an issue **and** has at least one blocker
96
+ * whose issue also exists.
97
+ *
98
+ * A group with no resolvable blocker is deliberately left alone rather than
99
+ * rewritten to an identical body: an issue-body update is a mutation and a
100
+ * notification, and doing it for a no-op edit is noise on every Story of every
101
+ * sweep.
102
+ *
103
+ * @param {object} args
104
+ * @returns {Array<{ groupKey: string, issueNumber: number, body: string, blockerKeys: string[] }>}
105
+ */
106
+ function collectWiredStories({ groups, edges, issueByGroupKey }) {
107
+ const wired = [];
108
+ for (const group of groups ?? []) {
109
+ const issueNumber = issueByGroupKey?.[group?.groupKey];
110
+ if (!Number.isInteger(issueNumber)) continue;
111
+ const rendered = buildStoryBody({ group, edges, issueByGroupKey });
112
+ const blockerKeys = rendered.dependsOn.filter((key) =>
113
+ Number.isInteger(issueByGroupKey[key]),
114
+ );
115
+ if (blockerKeys.length === 0) continue;
116
+ wired.push({
117
+ groupKey: rendered.groupKey,
118
+ issueNumber,
119
+ body: rendered.body,
120
+ blockerKeys,
121
+ });
122
+ }
123
+ return wired;
124
+ }
125
+
126
+ /**
127
+ * Mirror the declared edges as native GitHub `blocked_by` relations.
128
+ *
129
+ * Two shape hazards this crossing inherits from `plan-persist`, both silent if
130
+ * missed: `applyBlockedByDependencies` indexes `slugToIssueNumber` with plain
131
+ * property access (so it must be a plain object, never a `Map` — a `Map` yields
132
+ * `undefined` for every lookup, skips every edge, and reports success having
133
+ * written nothing), and it reads `dependsOn`, not `depends_on`.
134
+ *
135
+ * @param {object} args
136
+ * @returns {Promise<{ edgesAdded: number, edgesSkipped: number, edgesFailed: number }|null>}
137
+ * `null` when there is no interface to mirror through.
138
+ */
139
+ async function mirrorNativeEdges({ provider, wired, issueByGroupKey }) {
140
+ if (
141
+ typeof provider?.getDependencyWriteContext !== 'function' ||
142
+ typeof provider?.getTicket !== 'function'
143
+ ) {
144
+ Logger.warn(
145
+ '[audit-to-stories] provider exposes no getDependencyWriteContext/getTicket — ' +
146
+ 'skipping native blocked_by edges. Ordering survives in the ' +
147
+ '`blocked by #N` body footers just written.',
148
+ );
149
+ return null;
150
+ }
151
+ try {
152
+ const { gh, owner, repo } = provider.getDependencyWriteContext();
153
+ const summary = await applyBlockedByDependencies({
154
+ // The group key IS the slug here — it is the stable identifier both
155
+ // sides of an edge are keyed by — so `issueByGroupKey` is already the
156
+ // slug→number map the helper wants.
157
+ stories: wired.map((s) => ({
158
+ slug: s.groupKey,
159
+ dependsOn: s.blockerKeys,
160
+ })),
161
+ slugToIssueNumber: issueByGroupKey,
162
+ getTicket: (issueNumber) => provider.getTicket(issueNumber),
163
+ owner,
164
+ repo,
165
+ gh,
166
+ });
167
+ if (summary.edgesFailed > 0) {
168
+ Logger.warn(
169
+ `[audit-to-stories] ${summary.edgesFailed} native blocked_by edge(s) could ` +
170
+ 'not be written. Ordering survives in the `blocked by #N` body footers.',
171
+ );
172
+ }
173
+ return {
174
+ edgesAdded: summary.edgesAdded,
175
+ edgesSkipped: summary.edgesSkipped,
176
+ edgesFailed: summary.edgesFailed,
177
+ };
178
+ } catch (err) {
179
+ Logger.warn(
180
+ `[audit-to-stories] native blocked_by mirroring failed (${err.message}) — ` +
181
+ 'ordering survives in the `blocked by #N` body footers.',
182
+ );
183
+ return null;
184
+ }
185
+ }
@@ -30,9 +30,19 @@ export const DEFAULT_DECOMPOSER = Object.freeze({
30
30
  * `verifyWaveResults` loop it claimed to bound never existed in the tree, and
31
31
  * its only reader was the retired execution-analysis CLI, which echoed the
32
32
  * number into a report rather than bounding anything.
33
+ *
34
+ * **Serialization tradeoff — `footprintGuard`.** `enforce` is the default and
35
+ * stays it: the file-overlap guard encodes delivery-time-only knowledge (which
36
+ * implementation windows are open, which Stories a foreign lease holds) that no
37
+ * plan-time `depends_on` edge can carry, so demoting it by default would trade
38
+ * a real merge-conflict class for throughput nobody asked for. `advisory`
39
+ * detects collisions and reports every would-be withhold but lets dispatch
40
+ * follow the declared edges alone — for runs whose ordering is fully declared
41
+ * (Story #5044).
33
42
  */
34
43
  const DEFAULT_DELIVER_RUNNER = Object.freeze({
35
44
  concurrencyCap: 3,
45
+ footprintGuard: 'enforce',
36
46
  });
37
47
 
38
48
  /**
@@ -51,28 +61,40 @@ export const DEFAULT_CODE_REVIEW = Object.freeze({
51
61
  *
52
62
  * @param {object | null | undefined} config
53
63
  * @returns {{
54
- * deliverRunner: { concurrencyCap: number },
64
+ * deliverRunner: { concurrencyCap: number, footprintGuard: 'enforce'|'advisory' },
55
65
  * codeReview: { maxFixAttempts: number, maxFixScopeFiles: number, autoFixSeverity: 'high'|'medium' },
56
66
  * decomposer: { concurrencyCap: number },
57
67
  * }}
58
68
  */
59
69
  export function getRunners(config) {
60
- const deliverRunnerUser = config?.delivery?.deliverRunner ?? {};
61
- const codeReviewUser = config?.delivery?.codeReview ?? {};
62
70
  return {
63
- deliverRunner: {
64
- concurrencyCap:
65
- deliverRunnerUser.concurrencyCap ??
66
- DEFAULT_DELIVER_RUNNER.concurrencyCap,
67
- },
68
- codeReview: {
69
- maxFixAttempts:
70
- codeReviewUser.maxFixAttempts ?? DEFAULT_CODE_REVIEW.maxFixAttempts,
71
- maxFixScopeFiles:
72
- codeReviewUser.maxFixScopeFiles ?? DEFAULT_CODE_REVIEW.maxFixScopeFiles,
73
- autoFixSeverity:
74
- codeReviewUser.autoFixSeverity ?? DEFAULT_CODE_REVIEW.autoFixSeverity,
75
- },
71
+ deliverRunner: withDefaults(
72
+ DEFAULT_DELIVER_RUNNER,
73
+ config?.delivery?.deliverRunner,
74
+ ),
75
+ codeReview: withDefaults(DEFAULT_CODE_REVIEW, config?.delivery?.codeReview),
76
76
  decomposer: DEFAULT_DECOMPOSER,
77
77
  };
78
78
  }
79
+
80
+ /**
81
+ * Overlay an operator's block onto the framework defaults — the per-key `??`
82
+ * fallback these accessors have always applied, written once.
83
+ *
84
+ * Iterating the **defaults'** keys rather than the user's is what keeps the
85
+ * returned shape closed: a key the framework does not define cannot reach a
86
+ * consumer through here even if one somehow survived AJV, so a typo degrades to
87
+ * the default rather than to an undefined a caller would read as configuration.
88
+ *
89
+ * @template {Record<string, unknown>} T
90
+ * @param {T} defaults Frozen framework defaults.
91
+ * @param {object|null|undefined} user Operator block from `.agentrc`.
92
+ * @returns {T} A fresh object; the frozen defaults are never mutated.
93
+ */
94
+ function withDefaults(defaults, user) {
95
+ const out = { ...defaults };
96
+ for (const key of Object.keys(defaults)) {
97
+ if (user?.[key] != null) out[key] = user[key];
98
+ }
99
+ return out;
100
+ }
@@ -73,10 +73,18 @@ const DELIVER_RUNNER_SCHEMA = {
73
73
  minimum: 1,
74
74
  description:
75
75
  'Maximum ready Stories dispatched by /deliver at once. Default 3. Moderate by design — keeps host-quota consumption predictable while allowing a small ready-set fan-out. Set 1 for strictly sequential delivery; raise further on hosts with adequate parallel-agent quota. See deliver.md for the sequencing model and throughput tradeoff.',
76
- // getRunners() resolves this inline rather than from an exported
77
- // constant; the rewritten parity suite asserts the two agree.
76
+ // getRunners() resolves this from its own DEFAULT_DELIVER_RUNNER
77
+ // constant, not from this annotation; the parity suite asserts the two
78
+ // agree.
78
79
  default: 3,
79
80
  },
81
+ footprintGuard: {
82
+ type: 'string',
83
+ enum: ['enforce', 'advisory'],
84
+ description:
85
+ "How a file-footprint collision affects dispatch. 'enforce' (default, and the behaviour to keep unless you have a reason) withholds a Story whose footprint races a peer admitted this beat or one still in flight — the guard encodes delivery-time-only knowledge (open implementation windows, foreign leases, ground that moved since planning) that no depends_on edge can carry. 'advisory' still DETECTS every collision and reports each would-be withhold in the tick envelope, but lets dispatch follow the declared depends_on edges alone — a deliberate throughput trade for a run whose ordering is fully declared. See stories-wave-tick.js and helpers/deliver-reference.md.",
86
+ default: 'enforce',
87
+ },
80
88
  },
81
89
  additionalProperties: false,
82
90
  };
@@ -7,18 +7,31 @@
7
7
  * lib/story-adjacency.js, lib/branch-name-guard.js).
8
8
  */
9
9
 
10
+ import { parseFooterBlockedByIds } from './story-body/footer-block.js';
11
+
10
12
  /**
11
- * Parse `blocked by #NNN` and `depends on #NNN` references from text.
12
- * Handles case-insensitive variations.
13
+ * Parse a body's declared blocker issue numbers **footer-scoped and
14
+ * strict**.
15
+ *
16
+ * Only a `blocked by #N` line standing alone inside the `---` footer block
17
+ * declares an edge. The unanchored predecessor scanned the whole body for
18
+ * `blocked by|depends on #N` anywhere, so a Story whose prose merely mentioned
19
+ * a blocker — an example, a changelog note, an acceptance criterion describing
20
+ * this very defect — minted a real dispatch gate that withheld the Story until
21
+ * an unrelated issue closed.
22
+ *
23
+ * The behaviour change is deliberate and user-visible: prose-only mentions
24
+ * outside the footer no longer gate. Every machine-authored body already
25
+ * carries the canonical footer form (`plan-persist` has always serialized it),
26
+ * so only hand-written prose edges are affected — those must be moved into the
27
+ * footer block to keep gating. The grammar itself lives in
28
+ * `lib/story-body/footer-block.js`, shared with the body parser.
13
29
  *
14
30
  * @param {string} body - Issue body or freeform text.
15
- * @returns {number[]} Array of issue numbers this text declares as blockers.
31
+ * @returns {number[]} Array of issue numbers this body declares as blockers.
16
32
  */
17
33
  export function parseBlockedBy(body) {
18
- if (!body) return [];
19
- const re = /(?:blocked\s+by|depends\s+on):?\s+#(\d+)/gi;
20
- const ids = [...body.matchAll(re)].map((m) => Number.parseInt(m[1], 10));
21
- return [...new Set(ids)];
34
+ return parseFooterBlockedByIds(body);
22
35
  }
23
36
 
24
37
  /**
@@ -0,0 +1,135 @@
1
+ /**
2
+ * lib/findings/provenance-field.js — the per-Story `provenance` field.
3
+ *
4
+ * An audit-seeded plan carries dedup identities forward so the next sweep
5
+ * recognises what it already planned. The optional top-level `provenance`
6
+ * field on a `stories.json` entry says **which of them that Story owns**:
7
+ *
8
+ * ```jsonc
9
+ * { "fingerprints": ["<40-char sha1>"], "semanticKeys": ["architecture␟lib/a.js"] }
10
+ * ```
11
+ *
12
+ * Two callers, deliberately split from
13
+ * [`route-finding.js`](route-finding.js): the ticket validator shape-checks
14
+ * the authored field, and plan-persist's assembly renders the owned identities
15
+ * into the footer source it stamps. Neither is dedup *routing*, which is what
16
+ * `route-finding.js` is for — this module reads its identity vocabulary
17
+ * (`SHA1_RE`, `SEMANTIC_KEY_RE`, and the two footer renderers) from there so
18
+ * there is exactly one definition of what a fingerprint or a semantic key
19
+ * looks like.
20
+ *
21
+ * @module lib/findings/provenance-field
22
+ */
23
+
24
+ import {
25
+ fingerprintFooter,
26
+ SEMANTIC_KEY_RE,
27
+ SHA1_RE,
28
+ semanticKeyFooter,
29
+ } from './route-finding.js';
30
+
31
+ /** Human-readable rendering of the `provenance` field's two lists. */
32
+ const PROVENANCE_SHAPE = 'fingerprints[] / semanticKeys[]';
33
+
34
+ /** What each `provenance` list accepts, and how to say so when it does not. */
35
+ const PROVENANCE_FIELDS = Object.freeze({
36
+ fingerprints: { pattern: SHA1_RE, expected: 'a 40-char sha1 hex string' },
37
+ semanticKeys: {
38
+ pattern: SEMANTIC_KEY_RE,
39
+ expected: 'a non-empty key carrying no comma or ">"',
40
+ },
41
+ });
42
+
43
+ /**
44
+ * Validate one authored `provenance` list into its normalized form.
45
+ *
46
+ * @param {unknown} list
47
+ * @param {{ where: string, field: string, pattern: RegExp, expected: string }} spec
48
+ * @returns {string[]} Trimmed, de-duplicated, first-seen order.
49
+ */
50
+ function normalizeList(list, { where, field, pattern, expected }) {
51
+ if (list === null || list === undefined) return [];
52
+ if (!Array.isArray(list)) {
53
+ throw new Error(`${where}: ${field} must be an array of strings`);
54
+ }
55
+ const out = [];
56
+ for (const entry of list) {
57
+ const value = typeof entry === 'string' ? entry.trim() : '';
58
+ if (!pattern.test(value)) {
59
+ throw new Error(
60
+ `${where}: ${field} entry ${JSON.stringify(entry)} is not ${expected}`,
61
+ );
62
+ }
63
+ if (!out.includes(value)) out.push(value);
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /**
69
+ * Normalize the optional per-Story `provenance` field a plan may author —
70
+ * the identities of the findings **that Story owns**.
71
+ *
72
+ * Absence is meaningful and must stay cheap: `undefined` / `null` returns
73
+ * `null`, which is the caller's signal to fall back to the whole-seed union
74
+ * carry. That fallback is not vestigial — leaving the authoring agent to
75
+ * hand-carry provenance out of the seed's HTML comments was measured to fail,
76
+ * and the mechanical union is what closed it. Attribution is **additive**: a
77
+ * plan that attributes gets exact stamping, a plan that does not keeps recall.
78
+ *
79
+ * An empty object is therefore *not* the same as an absent field: it means
80
+ * "this Story owns nothing", and stamps nothing.
81
+ *
82
+ * Present-but-malformed is a hard error rather than a silent drop, because a
83
+ * dropped identity is invisible until the next sweep re-files work that was
84
+ * already planned.
85
+ *
86
+ * @param {unknown} raw
87
+ * @param {string} [label] Identifier for the error message (a Story slug).
88
+ * @returns {{ fingerprints: string[], semanticKeys: string[] }|null}
89
+ * @throws {Error} On any shape the stamper cannot honour exactly.
90
+ */
91
+ export function normalizeOwnedProvenance(raw, label = 'story') {
92
+ if (raw === undefined || raw === null) return null;
93
+ const where = `provenance on "${label}"`;
94
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
95
+ throw new Error(`${where} must be an object of ${PROVENANCE_SHAPE}`);
96
+ }
97
+ const out = { fingerprints: [], semanticKeys: [] };
98
+ for (const [field, list] of Object.entries(raw)) {
99
+ const spec = PROVENANCE_FIELDS[field];
100
+ if (!spec) {
101
+ throw new Error(
102
+ `${where} carries an unknown field: ${field} — only ${PROVENANCE_SHAPE} are stamped`,
103
+ );
104
+ }
105
+ out[field] = normalizeList(list, { where, field, ...spec });
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /**
111
+ * Render the provenance **source document** for a set of owned identities, in
112
+ * the same footer vocabulary `carryProvenanceFooters` harvests from an audit
113
+ * seed. That reuse is the point: attribution changes *which* identities reach
114
+ * a Story body, never how they are stamped, so the carry stays additive,
115
+ * union-preserving and idempotent for an attributed plan exactly as it is for
116
+ * an un-attributed one.
117
+ *
118
+ * An empty (or absent) set renders the empty string, which the carry treats as
119
+ * nothing-to-do — so a Story that owns no findings is stamped with none rather
120
+ * than inheriting its siblings'.
121
+ *
122
+ * Expects the normalized shape {@link normalizeOwnedProvenance} returns; the
123
+ * validator runs first on every production path.
124
+ *
125
+ * @param {{ fingerprints?: string[], semanticKeys?: string[] }|null} [provenance]
126
+ * @returns {string}
127
+ */
128
+ export function ownedProvenanceSource(provenance) {
129
+ const shas = provenance?.fingerprints ?? [];
130
+ const keys = provenance?.semanticKeys ?? [];
131
+ const parts = [];
132
+ if (shas.length > 0) parts.push(fingerprintFooter(shas));
133
+ if (keys.length > 0) parts.push(semanticKeyFooter(keys));
134
+ return parts.join('\n');
135
+ }