mandrel 2.31.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 (267) hide show
  1. package/.agents/README.md +13 -17
  2. package/.agents/agents/acceptance-critic.md +1 -2
  3. package/.agents/docs/SDLC.md +12 -9
  4. package/.agents/docs/agentrc-reference.json +63 -58
  5. package/.agents/docs/configuration.md +275 -227
  6. package/.agents/docs/execution-reference.md +13 -14
  7. package/.agents/docs/quality-gates.md +195 -23
  8. package/.agents/instructions.md +2 -5
  9. package/.agents/rules/git-conventions-reference.md +27 -27
  10. package/.agents/rules/git-conventions.md +4 -2
  11. package/.agents/rules/known-tooling-behavior.md +66 -30
  12. package/.agents/rules/testing-standards.md +35 -71
  13. package/.agents/runtime-deps.json +2 -2
  14. package/.agents/schemas/agentrc.schema.json +1955 -1410
  15. package/.agents/schemas/lifecycle/README.md +21 -14
  16. package/.agents/schemas/lifecycle/ledger-record.schema.json +76 -22
  17. package/.agents/schemas/story-deliver-terminal.schema.json +2 -2
  18. package/.agents/scripts/README.md +16 -29
  19. package/.agents/scripts/apply-quality-bootstrap.js +27 -34
  20. package/.agents/scripts/audit-to-stories.js +160 -41
  21. package/.agents/scripts/bootstrap.js +28 -26
  22. package/.agents/scripts/check-baseline-drift.js +73 -13
  23. package/.agents/scripts/check-baseline-scope.js +362 -0
  24. package/.agents/scripts/check-dead-exports.js +9 -1
  25. package/.agents/scripts/check-gherkin-corpus.js +508 -0
  26. package/.agents/scripts/check-knip-entries.js +159 -0
  27. package/.agents/scripts/check-lifecycle-lint.js +108 -124
  28. package/.agents/scripts/check-schema-references.js +1 -1
  29. package/.agents/scripts/diagnose-friction.js +7 -4
  30. package/.agents/scripts/generate-config-docs.js +263 -171
  31. package/.agents/scripts/install-matrix-assert.js +0 -1
  32. package/.agents/scripts/lib/ITicketingProvider.js +0 -58
  33. package/.agents/scripts/lib/audit-baselines/staleness.js +6 -6
  34. package/.agents/scripts/lib/audit-baselines/trend.js +7 -8
  35. package/.agents/scripts/lib/audit-baselines/weights.js +4 -5
  36. package/.agents/scripts/lib/audit-suite/checklist-threading.js +1 -1
  37. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +81 -35
  38. package/.agents/scripts/lib/audit-to-stories/wire-dependencies.js +185 -0
  39. package/.agents/scripts/lib/baselines/envelope.js +41 -60
  40. package/.agents/scripts/lib/baselines/git-base.js +30 -37
  41. package/.agents/scripts/lib/baselines/kinds/_crap-new-method-gate.js +103 -0
  42. package/.agents/scripts/lib/baselines/kinds/_crap-read.js +150 -0
  43. package/.agents/scripts/lib/baselines/kinds/crap.js +25 -65
  44. package/.agents/scripts/lib/baselines/orphan-pruner.js +233 -0
  45. package/.agents/scripts/lib/baselines/refresh-service.js +6 -8
  46. package/.agents/scripts/lib/baselines/scope-assert.js +223 -0
  47. package/.agents/scripts/lib/baselines/scope-inventory.js +314 -0
  48. package/.agents/scripts/lib/bdd-step-index.js +326 -0
  49. package/.agents/scripts/lib/bootstrap/install-ledger.js +5 -3
  50. package/.agents/scripts/lib/bootstrap/issue-forms-template.js +4 -6
  51. package/.agents/scripts/lib/bootstrap/manifest.js +17 -40
  52. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +12 -59
  53. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +62 -2
  54. package/.agents/scripts/lib/checks/loop-health.js +9 -37
  55. package/.agents/scripts/lib/child-exec.js +193 -0
  56. package/.agents/scripts/lib/cli/standard-args.js +1 -1
  57. package/.agents/scripts/lib/cli-args.js +64 -0
  58. package/.agents/scripts/lib/close-validation/gates.js +2 -2
  59. package/.agents/scripts/lib/close-validation/runner.js +3 -3
  60. package/.agents/scripts/lib/config/acceptance-eval.js +5 -52
  61. package/.agents/scripts/lib/config/commands.js +3 -5
  62. package/.agents/scripts/lib/config/explain.js +5 -7
  63. package/.agents/scripts/lib/config/gates/bundle-size.schema.js +32 -6
  64. package/.agents/scripts/lib/config/gates/coverage.schema.js +25 -5
  65. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +12 -2
  66. package/.agents/scripts/lib/config/gates/crap.schema.js +68 -23
  67. package/.agents/scripts/lib/config/gates/duplication.schema.js +29 -17
  68. package/.agents/scripts/lib/config/gates/index.js +5 -2
  69. package/.agents/scripts/lib/config/gates/lighthouse.schema.js +34 -6
  70. package/.agents/scripts/lib/config/gates/lint.schema.js +11 -2
  71. package/.agents/scripts/lib/config/gates/maintainability.schema.js +37 -15
  72. package/.agents/scripts/lib/config/gates/mutation.schema.js +15 -3
  73. package/.agents/scripts/lib/config/gates/shared.js +58 -9
  74. package/.agents/scripts/lib/config/github.js +0 -1
  75. package/.agents/scripts/lib/config/limits.js +3 -48
  76. package/.agents/scripts/lib/config/qa.js +105 -0
  77. package/.agents/scripts/lib/config/runners.js +38 -16
  78. package/.agents/scripts/lib/config/temp-paths.js +6 -5
  79. package/.agents/scripts/lib/config-settings-schema-delivery.js +245 -56
  80. package/.agents/scripts/lib/config-settings-schema-quality.js +209 -29
  81. package/.agents/scripts/lib/config-settings-schema.js +386 -39
  82. package/.agents/scripts/lib/crap-baseline-join.js +126 -9
  83. package/.agents/scripts/lib/crap-utils.js +84 -520
  84. package/.agents/scripts/lib/dead-exports-knip.js +79 -10
  85. package/.agents/scripts/lib/degraded-mode.js +2 -2
  86. package/.agents/scripts/lib/dependency-parser.js +20 -7
  87. package/.agents/scripts/lib/doc-tiers.js +3 -3
  88. package/.agents/scripts/lib/feedback-loop/graduator-core.js +46 -104
  89. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +10 -8
  90. package/.agents/scripts/lib/findings/provenance-field.js +135 -0
  91. package/.agents/scripts/lib/findings/route-finding.js +57 -8
  92. package/.agents/scripts/lib/fs-walk.js +52 -0
  93. package/.agents/scripts/lib/git-branch-lifecycle.js +2 -2
  94. package/.agents/scripts/lib/git-utils.js +16 -36
  95. package/.agents/scripts/lib/knip-config-resolver.js +181 -0
  96. package/.agents/scripts/lib/knip-entry-sync.js +508 -0
  97. package/.agents/scripts/lib/observability/metrics-ledger.js +1 -1
  98. package/.agents/scripts/lib/observability/runtime-friction.js +10 -0
  99. package/.agents/scripts/lib/observability/signal-validator.js +5 -85
  100. package/.agents/scripts/lib/observability/signals-writer.js +19 -62
  101. package/.agents/scripts/lib/observability/source-classifier.js +5 -7
  102. package/.agents/scripts/lib/observability/terse-result.js +3 -3
  103. package/.agents/scripts/lib/orchestration/behind-recovery.js +114 -0
  104. package/.agents/scripts/lib/orchestration/ceremony-routing.js +7 -8
  105. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +34 -33
  106. package/.agents/scripts/lib/orchestration/code-review.js +2 -2
  107. package/.agents/scripts/lib/orchestration/complexity-gate.js +43 -161
  108. package/.agents/scripts/lib/orchestration/diff-magnitude.js +4 -4
  109. package/.agents/scripts/lib/orchestration/label-transitions.js +3 -2
  110. package/.agents/scripts/lib/orchestration/lease-guard-shared.js +12 -38
  111. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +5 -6
  112. package/.agents/scripts/lib/orchestration/plan-metrics.js +2 -3
  113. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +7 -26
  114. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +69 -5
  115. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +69 -13
  116. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +49 -0
  117. package/.agents/scripts/lib/orchestration/{lifecycle/listeners/watcher.js → pr-watch.js} +58 -208
  118. package/.agents/scripts/lib/orchestration/resolve-stories.js +77 -50
  119. package/.agents/scripts/lib/orchestration/review-providers/codex.js +1 -1
  120. package/.agents/scripts/lib/orchestration/review-providers/mi-exemptions.js +130 -0
  121. package/.agents/scripts/lib/orchestration/review-providers/native.js +30 -16
  122. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +1 -1
  123. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +37 -26
  124. package/.agents/scripts/lib/orchestration/single-story-close/phases/conventional-subject.js +376 -0
  125. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +161 -151
  126. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +15 -3
  127. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +10 -15
  128. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-block.js +5 -0
  129. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +157 -0
  130. package/.agents/scripts/lib/orchestration/single-story-close/phases/worktree-reap.js +0 -14
  131. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +59 -25
  132. package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +20 -31
  133. package/.agents/scripts/lib/orchestration/spec-spill.js +17 -3
  134. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +7 -6
  135. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +2 -1
  136. package/.agents/scripts/lib/orchestration/task-body-validator.js +4 -1
  137. package/.agents/scripts/lib/orchestration/ticket-lease.js +28 -127
  138. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +116 -1
  139. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +1 -1
  140. package/.agents/scripts/lib/orchestration/ticket-validator.js +38 -0
  141. package/.agents/scripts/lib/orchestration/ticketing/reads.js +5 -5
  142. package/.agents/scripts/lib/orchestration/ticketing/transition.js +5 -4
  143. package/.agents/scripts/lib/orchestration/verify-tier-repair.js +107 -0
  144. package/.agents/scripts/lib/qa/coverage-verdict.js +5 -87
  145. package/.agents/scripts/lib/signals/detectors/common.js +1 -1
  146. package/.agents/scripts/lib/signals/index.js +8 -6
  147. package/.agents/scripts/lib/signals/schema.js +20 -25
  148. package/.agents/scripts/lib/signals/write.js +8 -8
  149. package/.agents/scripts/lib/story-body/footer-block.js +97 -0
  150. package/.agents/scripts/lib/story-body/story-body.js +18 -81
  151. package/.agents/scripts/lib/temp-retention.js +1 -1
  152. package/.agents/scripts/lib/templates/decomposer-prompts.js +16 -14
  153. package/.agents/scripts/lib/ticket-body-sections.js +4 -5
  154. package/.agents/scripts/lib/wave-runner/footprint.js +306 -0
  155. package/.agents/scripts/lib/wave-runner/ready-set.js +198 -181
  156. package/.agents/scripts/lib/worktree/lifecycle/merge-reachability.js +13 -45
  157. package/.agents/scripts/lib/worktree/lifecycle/reap.js +4 -5
  158. package/.agents/scripts/lib/worktree-manager.js +2 -3
  159. package/.agents/scripts/lint-label-vocabulary.js +2 -24
  160. package/.agents/scripts/pr-watch-with-update.js +7 -5
  161. package/.agents/scripts/providers/github/blocked-by-add.js +25 -10
  162. package/.agents/scripts/providers/github/cache.js +2 -2
  163. package/.agents/scripts/providers/github/comments.js +6 -28
  164. package/.agents/scripts/providers/github/compose.js +0 -15
  165. package/.agents/scripts/providers/github/errors.js +10 -27
  166. package/.agents/scripts/providers/github/request-helpers.js +1 -2
  167. package/.agents/scripts/providers/github/sub-issues.js +10 -218
  168. package/.agents/scripts/providers/github.js +4 -7
  169. package/.agents/scripts/prune-baseline-orphans.js +181 -0
  170. package/.agents/scripts/resolve-stories.js +21 -7
  171. package/.agents/scripts/run-lint.js +61 -61
  172. package/.agents/scripts/run-test-profile.js +6 -6
  173. package/.agents/scripts/run-verify.js +48 -30
  174. package/.agents/scripts/single-story-close.js +20 -0
  175. package/.agents/scripts/single-story-init.js +12 -35
  176. package/.agents/scripts/stories-wave-tick.js +192 -9
  177. package/.agents/scripts/update-dead-exports-baseline.js +321 -0
  178. package/.agents/skills/core/gates-and-baselines/SKILL.md +2 -2
  179. package/.agents/skills/skills.index.json +1 -11
  180. package/.agents/workflows/audit-documentation.md +5 -6
  181. package/.agents/workflows/audit-to-stories.md +28 -2
  182. package/.agents/workflows/helpers/audit-lens-core.md +11 -12
  183. package/.agents/workflows/helpers/code-quality-guardrails.md +15 -14
  184. package/.agents/workflows/helpers/code-review.md +3 -8
  185. package/.agents/workflows/helpers/deliver-reference.md +30 -2
  186. package/.agents/workflows/helpers/deliver-story-reference.md +84 -16
  187. package/.agents/workflows/helpers/plan-reference.md +76 -0
  188. package/.agents/workflows/helpers/worktree-lifecycle.md +1 -2
  189. package/.agents/workflows/mandrel-update.md +10 -10
  190. package/.agents/workflows/qa-assist.md +15 -20
  191. package/.agents/workflows/qa-explore.md +9 -8
  192. package/README.md +1 -1
  193. package/docs/CHANGELOG.md +58 -0
  194. package/lib/migrations/index.js +2 -0
  195. package/lib/migrations/steps/2.32.0-retire-lint-baseline-command.js +127 -0
  196. package/package.json +14 -5
  197. package/.agents/schemas/lifecycle/checkpoint.written.schema.json +0 -13
  198. package/.agents/schemas/lifecycle/close-validate.end.schema.json +0 -18
  199. package/.agents/schemas/lifecycle/close-validate.start.schema.json +0 -13
  200. package/.agents/schemas/lifecycle/code-review.end.schema.json +0 -30
  201. package/.agents/schemas/lifecycle/code-review.start.schema.json +0 -12
  202. package/.agents/schemas/lifecycle/intervention.recorded.schema.json +0 -15
  203. package/.agents/schemas/lifecycle/loop.tick.schema.json +0 -20
  204. package/.agents/schemas/lifecycle/notification.emitted.schema.json +0 -18
  205. package/.agents/schemas/lifecycle/pr.created.schema.json +0 -14
  206. package/.agents/schemas/lifecycle/retro.end.schema.json +0 -16
  207. package/.agents/schemas/lifecycle/retro.start.schema.json +0 -12
  208. package/.agents/schemas/lifecycle/story.blocked.schema.json +0 -13
  209. package/.agents/schemas/lifecycle/story.dispatch.end.schema.json +0 -17
  210. package/.agents/schemas/lifecycle/story.dispatch.start.schema.json +0 -15
  211. package/.agents/schemas/lifecycle/story.merged.schema.json +0 -13
  212. package/.agents/scripts/check-gherkin-placeholders.js +0 -663
  213. package/.agents/scripts/check-lifecycle-doc-drift.js +0 -411
  214. package/.agents/scripts/lib/audit-suite/cli.js +0 -64
  215. package/.agents/scripts/lib/bootstrap/baselines-layout-migration.js +0 -202
  216. package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +0 -212
  217. package/.agents/scripts/lib/checks/baseline-drift-main-checkout.js +0 -104
  218. package/.agents/scripts/lib/checks/push-hook-parity.js +0 -106
  219. package/.agents/scripts/lib/checks/windows-coverage-noise-floor.js +0 -92
  220. package/.agents/scripts/lib/checks/worktree-bootstrap-env.js +0 -81
  221. package/.agents/scripts/lib/checks/worktree-residue-biome.js +0 -55
  222. package/.agents/scripts/lib/crap-baseline-index.js +0 -46
  223. package/.agents/scripts/lib/crap-utils-incremental.js +0 -113
  224. package/.agents/scripts/lib/dynamic-workflow/capability.js +0 -396
  225. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -335
  226. package/.agents/scripts/lib/mutation/baseline-snapshot.js +0 -239
  227. package/.agents/scripts/lib/mutation/config-detector.js +0 -119
  228. package/.agents/scripts/lib/mutation/stryker-runner.js +0 -306
  229. package/.agents/scripts/lib/mutation/survivor-report.js +0 -160
  230. package/.agents/scripts/lib/observability/active-story-env.js +0 -170
  231. package/.agents/scripts/lib/observability/tool-trace-hook.js +0 -456
  232. package/.agents/scripts/lib/orchestration/acceptance-clusters.js +0 -111
  233. package/.agents/scripts/lib/orchestration/context-envelope.js +0 -277
  234. package/.agents/scripts/lib/orchestration/detectors-phase.js +0 -194
  235. package/.agents/scripts/lib/orchestration/lifecycle/bus.js +0 -309
  236. package/.agents/scripts/lib/orchestration/lifecycle/emit-loop-tick.js +0 -181
  237. package/.agents/scripts/lib/orchestration/lifecycle/ledger-writer.js +0 -229
  238. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +0 -54
  239. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -344
  240. package/.agents/scripts/lib/orchestration/lint-baseline-service.js +0 -114
  241. package/.agents/scripts/lib/orchestration/pr-base-guard.js +0 -37
  242. package/.agents/scripts/lib/orchestration/resolves-token.js +0 -127
  243. package/.agents/scripts/lib/orchestration/spec-section-validator.js +0 -130
  244. package/.agents/scripts/lib/orchestration/story-close/emit-blocked.js +0 -55
  245. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +0 -211
  246. package/.agents/scripts/lib/planning-corpus.js +0 -37
  247. package/.agents/scripts/lib/qa/coverage-report.js +0 -181
  248. package/.agents/scripts/lib/qa/propose-missing-test.js +0 -95
  249. package/.agents/scripts/lib/qa/qa-context-hydrator.js +0 -217
  250. package/.agents/scripts/lib/signals/detectors/index.js +0 -14
  251. package/.agents/scripts/lib/signals/detectors/retry.js +0 -253
  252. package/.agents/scripts/lib/signals/detectors/rework.js +0 -167
  253. package/.agents/scripts/lib/signals/read.js +0 -268
  254. package/.agents/scripts/lib/signals/span-tree.js +0 -291
  255. package/.agents/scripts/lib/story-lifecycle.js +0 -194
  256. package/.agents/scripts/lib/story-plan.js +0 -379
  257. package/.agents/scripts/lib/util/phase-timer-state.js +0 -72
  258. package/.agents/scripts/lib/util/phase-timer.js +0 -163
  259. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +0 -169
  260. package/.agents/scripts/lint-baseline.js +0 -507
  261. package/.agents/scripts/providers/github/prs.js +0 -103
  262. package/.agents/scripts/signals-view.js +0 -309
  263. package/.agents/scripts/story-plan.js +0 -370
  264. package/.agents/scripts/sync-branch-from-base.js +0 -149
  265. package/.agents/scripts/validate-docs-freshness.js +0 -314
  266. package/.agents/skills/core/diagnose-friction/SKILL.md +0 -78
  267. package/.agents/workflows/helpers/signals.md +0 -112
@@ -75,11 +75,80 @@ export function readKnipOutput(filePath) {
75
75
  }
76
76
  }
77
77
 
78
+ /**
79
+ * Sentinel `symbol` recorded for a **whole-file death** row.
80
+ *
81
+ * Knip's `files` category names a module nothing in the graph imports. There
82
+ * is no per-export identity to record — the whole file is the finding — so the
83
+ * ratchet encodes exactly one row per dead file carrying this symbol. `*` is
84
+ * not a legal JavaScript identifier, so a whole-file row can never collide
85
+ * with a real export row for the same path.
86
+ */
87
+ const WHOLE_FILE_SYMBOL = '*';
88
+
89
+ /**
90
+ * Pull the dead-file paths out of one knip issue's `files` category.
91
+ *
92
+ * Knip emits `files: [{ name: '<path>' }]`, but tolerate a bare string and
93
+ * fall back to the issue's own `file` so a reporter-shape change degrades to
94
+ * "one row for this path" rather than to silence.
95
+ *
96
+ * @param {{ files?: unknown }} issue
97
+ * @param {string} fallbackFile The issue's own `file` path.
98
+ * @returns {string[]}
99
+ */
100
+ function extractDeadFileNames(issue, fallbackFile) {
101
+ const entries = Array.isArray(issue.files) ? issue.files : [];
102
+ const names = [];
103
+ for (const entry of entries) {
104
+ const name =
105
+ (typeof entry === 'string' && entry) ||
106
+ (entry && typeof entry.name === 'string' && entry.name) ||
107
+ fallbackFile;
108
+ if (typeof name === 'string' && name.length > 0) names.push(name);
109
+ }
110
+ return names;
111
+ }
112
+
113
+ /**
114
+ * Pull the dead-export symbol names out of one knip issue's `exports`
115
+ * category. Knip emits `exports: [{ name, ... }]`; older shapes used `symbol`.
116
+ *
117
+ * @param {{ exports?: unknown }} issue
118
+ * @returns {string[]}
119
+ */
120
+ function extractDeadExportSymbols(issue) {
121
+ const entries = Array.isArray(issue.exports) ? issue.exports : [];
122
+ const symbols = [];
123
+ for (const e of entries) {
124
+ const symbol =
125
+ (e && typeof e.name === 'string' && e.name) ||
126
+ (e && typeof e.symbol === 'string' && e.symbol) ||
127
+ null;
128
+ if (symbol) symbols.push(symbol);
129
+ }
130
+ return symbols;
131
+ }
132
+
78
133
  /**
79
134
  * Flatten knip's `--reporter json` output into `{ file, symbol }` rows. Knip
80
- * emits `{ issues: [{ file, exports: [{ name, ... }], ... }, ...] }`. Only
81
- * `exports` rows are mapped — the ratchet ignores file-, dependency- and
82
- * duplicate-level issues, which knip surfaces under separate `rules` keys.
135
+ * emits `{ issues: [{ file, files: [...], exports: [{ name, ... }], ... }] }`.
136
+ *
137
+ * Two categories are mapped, and the ratchet treats their rows identically:
138
+ *
139
+ * - **`exports`** → one row per unused export, `{ file, symbol: '<name>' }`.
140
+ * - **`files`** → one row per module nothing imports,
141
+ * `{ file, symbol: '*' }`. Story #5001 added this leg: mapping only
142
+ * `exports` made the ratchet structurally blind to *whole-file* death, so a
143
+ * module could lose its last caller and every one of its exports go unused
144
+ * without a single row changing. Knip reports such a module once, under
145
+ * `files`, and suppresses its per-export rows — which is exactly why the
146
+ * export-only reading saw nothing.
147
+ *
148
+ * Whole-file rows are de-duplicated by path so the row set is stable across
149
+ * runs regardless of how many issue records mention the same file. Dependency-
150
+ * and duplicate-level issues stay ignored; knip surfaces those under their own
151
+ * `rules` keys and they are not a code-death signal.
83
152
  *
84
153
  * @param {unknown} knipEnvelope The parsed knip JSON report.
85
154
  * @returns {Array<{ file: string, symbol: string }>}
@@ -88,16 +157,16 @@ export function extractRowsFromKnip(knipEnvelope) {
88
157
  const rows = [];
89
158
  if (!knipEnvelope || typeof knipEnvelope !== 'object') return rows;
90
159
  const issues = Array.isArray(knipEnvelope.issues) ? knipEnvelope.issues : [];
160
+ const seenDeadFiles = new Set();
91
161
  for (const issue of issues) {
92
162
  const file = issue?.file;
93
163
  if (typeof file !== 'string' || file.length === 0) continue;
94
- const exports_ = Array.isArray(issue.exports) ? issue.exports : [];
95
- for (const e of exports_) {
96
- const symbol =
97
- (e && typeof e.name === 'string' && e.name) ||
98
- (e && typeof e.symbol === 'string' && e.symbol) ||
99
- null;
100
- if (!symbol) continue;
164
+ for (const name of extractDeadFileNames(issue, file)) {
165
+ if (seenDeadFiles.has(name)) continue;
166
+ seenDeadFiles.add(name);
167
+ rows.push({ file: name, symbol: WHOLE_FILE_SYMBOL });
168
+ }
169
+ for (const symbol of extractDeadExportSymbols(issue)) {
101
170
  rows.push({ file, symbol });
102
171
  }
103
172
  }
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Tech Spec #819 §"Degraded-mode contract (Story 3)" — three soft-fail sites
5
5
  * (audit-suite selector diff-timeout, lint-baseline JSON-parse, baseline-refresh
6
- * guardrail git-diff) historically returned a silent zero/empty result. The
7
- * new contract is:
6
+ * guardrail git-diff) historically returned a silent zero/empty result. Two
7
+ * remain; the lint-baseline site left with its CLI. The contract is:
8
8
  *
9
9
  * { ok: false, degraded: true, reason: <code>, detail: <human> }
10
10
  *
@@ -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
  /**
@@ -201,9 +201,9 @@ export function resolveAlwaysLoadedClosure(root, { fs = nodeFs } = {}) {
201
201
 
202
202
  /**
203
203
  * Read the resolved `project.docsContextFiles` list from a config object,
204
- * prefixed by `project.paths.docsRoot`. Mirrors the `contextDocs` half of
205
- * `resolveDocList` in `validate-docs-freshness.js` (new-shape first, legacy
206
- * top-level fallback).
204
+ * prefixed by `project.paths.docsRoot`. Resolves new-shape
205
+ * `project.docsContextFiles` first and falls back to the legacy top-level
206
+ * key, so a config written against either shape yields the same list.
207
207
  *
208
208
  * @param {object} config resolved config (`resolveConfig()` output)
209
209
  * @returns {string[]} repo-relative posix doc paths (existence not yet checked)
@@ -1,15 +1,19 @@
1
1
  /**
2
2
  * graduator-core.js — shared mechanism for the feedback-loop graduators.
3
3
  *
4
- * Story #3845 / Epic #3823. The audit-results graduator
5
- * (`audit-results-graduator.js`) and the code-review graduator
6
- * (the since-retired `code-review-graduator.js`) duplicated ~90% of their mechanism: a
4
+ * Story #3845 / Epic #3823. The since-retired audit-results and code-review
5
+ * graduators duplicated ~90% of their mechanism: a
7
6
  * `spawn`-based child runner, the `git cat-file` path probe, the
8
7
  * `gh search issues` idempotency probe, the `gh issue create` filer, the
9
8
  * `isAutoFileEnabled` toggle reader, and the route → probe → file
10
- * envelope walk. This module folds all of that into one place so the two
11
- * graduators become thin shells that inject the bits that genuinely
12
- * differ (the finding parser and the body/title/label builders).
9
+ * envelope walk. This module folds all of that into one place so a
10
+ * graduator becomes a thin shell that injects the bits that genuinely
11
+ * differ (the body/title/label builders).
12
+ *
13
+ * Story #5003 removed the structured-comment read limb. Both comment-reading
14
+ * graduators were Epic-era — they walked an Epic's `verification-results`
15
+ * comment, and v2 has no Epics — leaving `retro-proposals-graduator.js` and
16
+ * its pre-parsed `findings[]` as the one live caller.
13
17
  *
14
18
  * The third `runGh` spawn copy in `prior-feedback-fetcher.js` is also
15
19
  * collapsed onto the single `runChild` helper here.
@@ -34,11 +38,10 @@
34
38
  * - **Per-run filing cap.** `graduate()` stops filing once
35
39
  * `maxFilingsPerRun` issues are created and records the excess as
36
40
  * skipped `cap-reached`.
37
- * - **Pre-parsed / path-less seam.** `graduate()` accepts a pre-parsed
38
- * `findings` array (bypassing structured-comment parsing), and a
39
- * path-less finding skips the path-exists gate instead of being
40
- * misclassified `file-removed` — the seam the retro auto-filer
41
- * consumes.
41
+ * - **Pre-parsed / path-less seam.** `graduate()` takes its findings as a
42
+ * pre-parsed array, and a path-less finding skips the path-exists gate
43
+ * instead of being misclassified `file-removed` the seam the retro
44
+ * auto-filer consumes.
42
45
  * - **Durable cross-repo deferral.** Cross-repo-deferred findings are
43
46
  * upserted into a structured comment on the Epic instead of only a
44
47
  * log line.
@@ -50,34 +53,7 @@ import { createHash } from 'node:crypto';
50
53
  import { inNodeTestContext } from '../config/temp-paths.js';
51
54
  import { LABEL_COLORS } from '../label-constants.js';
52
55
  import { classifyPathSource as defaultClassifier } from '../observability/source-classifier.js';
53
- import {
54
- structuredCommentMarker,
55
- upsertStructuredComment,
56
- } from '../orchestration/ticketing.js';
57
-
58
- /**
59
- * The single structured-comment marker for the unified `verification-results`
60
- * findings contract (Story #4411, Epic #4405). It replaces the two retired
61
- * per-graduator markers (the former code-review and audit-results
62
- * structured-comment markers). Both feedback-loop graduators
63
- * search the Epic's comments for this one marker so they file follow-ups
64
- * from the same unified source comment that `runCodeReview` upserts (comment
65
- * type `verification-results`). Derived from the canonical
66
- * `structuredCommentMarker` builder so the read-side marker stays byte-stable
67
- * with the write side rather than being hand-copied.
68
- */
69
- export const VERIFICATION_RESULTS_MARKER = structuredCommentMarker(
70
- 'verification-results',
71
- );
72
-
73
- /**
74
- * The single "no source comment" skip reason for the unified contract. Both
75
- * graduators surface this reason when the Epic carries no
76
- * `verification-results` comment, replacing the two retired
77
- * `no-code-review-comment` / `no-audit-results-comment` reasons.
78
- */
79
- export const NO_VERIFICATION_RESULTS_COMMENT_REASON =
80
- 'no-verification-results-comment';
56
+ import { upsertStructuredComment } from '../orchestration/ticketing.js';
81
57
 
82
58
  /**
83
59
  * Default child-process timeout. A hung `gh`/`git` spawn previously blocked
@@ -814,28 +790,24 @@ export async function createFollowUpIssue({
814
790
  }
815
791
 
816
792
  /**
817
- * Validate the `graduate` preconditions (toggle, epicId, provider shape,
818
- * currentRepo shape). Returns `null` when all preconditions pass, or a
819
- * `{ skipped?, errors? }` partial-envelope the caller short-circuits on.
820
- * Story #4075 — extracted from `graduate` so the orchestrating body holds
821
- * no guard-chain branching.
793
+ * Validate the `graduate` preconditions (toggle, epicId, currentRepo shape).
794
+ * Returns `null` when all preconditions pass, or a `{ skipped?, errors? }`
795
+ * partial-envelope the caller short-circuits on. Story #4075 — extracted from
796
+ * `graduate` so the orchestrating body holds no guard-chain branching.
797
+ *
798
+ * Story #5003 dropped the `provider.getTicketComments` gate with the
799
+ * structured-comment read limb. The provider is now consulted only by the
800
+ * best-effort cross-repo-deferred upsert, which reports its own faults into
801
+ * `errors[]` — gating the whole walk on a shape only that path needs would
802
+ * refuse work the walk can complete.
822
803
  */
823
- function checkGraduatePreconditions({
824
- epicId,
825
- provider,
826
- currentRepo,
827
- config,
828
- spec,
829
- }) {
804
+ function checkGraduatePreconditions({ epicId, currentRepo, config, spec }) {
830
805
  if (!spec.isAutoFileEnabled(config)) {
831
806
  return { skipped: [{ reason: 'toggle-disabled' }] };
832
807
  }
833
808
  if (!Number.isInteger(epicId) || epicId < 1) {
834
809
  return { errors: [`${spec.fnName}: missing or invalid epicId`] };
835
810
  }
836
- if (!provider || typeof provider.getTicketComments !== 'function') {
837
- return { errors: [`${spec.fnName}: provider lacks getTicketComments`] };
838
- }
839
811
  if (
840
812
  !currentRepo ||
841
813
  typeof currentRepo.owner !== 'string' ||
@@ -846,36 +818,6 @@ function checkGraduatePreconditions({
846
818
  return null;
847
819
  }
848
820
 
849
- /**
850
- * Read the source structured comment off the Epic and parse its findings.
851
- * Returns `{ findings }` on success, or `{ skipped?, errors? }` for the
852
- * no-comment / parse-empty / fetch-error short-circuits. Story #4075 —
853
- * extracted from `graduate`.
854
- */
855
- async function loadGraduateFindings({ epicId, provider, spec }) {
856
- let comments;
857
- try {
858
- comments = await provider.getTicketComments(epicId);
859
- } catch (err) {
860
- return {
861
- errors: [
862
- `getTicketComments failed for epic #${epicId}: ${err?.message ?? err}`,
863
- ],
864
- };
865
- }
866
- const matched = (Array.isArray(comments) ? comments : []).filter(
867
- (c) => typeof c?.body === 'string' && c.body.includes(spec.commentMarker),
868
- );
869
- if (matched.length === 0) {
870
- return { skipped: [{ reason: spec.noCommentReason }] };
871
- }
872
- const findings = spec.parseFindings(matched[matched.length - 1].body);
873
- if (findings.length === 0) {
874
- return { skipped: [{ reason: 'no-non-blocking-findings' }] };
875
- }
876
- return { findings };
877
- }
878
-
879
821
  /**
880
822
  * Probe whether a finding was already filed, checking both the current
881
823
  * content-hash marker AND the legacy `(epicId, parse-index)` marker so
@@ -1267,17 +1209,16 @@ async function persistCrossRepoDeferred({
1267
1209
  }
1268
1210
 
1269
1211
  /**
1270
- * Parametrized graduator walk. Parses non-blocking findings (from the
1271
- * Epic's structured comment, or a pre-parsed `findings` array), then for
1272
- * each finding runs the shared route → path probe → idempotency probe →
1212
+ * Parametrized graduator walk. Takes a pre-parsed `findings` array and, for
1213
+ * each finding, runs the shared route path probe → idempotency probe →
1273
1214
  * cap → file sequence. Never throws — every failure path is captured in
1274
1215
  * `errors[]`.
1275
1216
  *
1217
+ * Each finding MUST carry `{ severity, path, summary, index }` and MAY carry
1218
+ * additional fields (e.g. `category`) that the builders use.
1219
+ *
1276
1220
  * The per-graduator variation lives entirely in the injected callbacks:
1277
1221
  *
1278
- * - `parseFindings(body)` — turns the rendered comment into findings.
1279
- * Each finding MUST carry `{ severity, path, summary, index }` and
1280
- * MAY carry additional fields (e.g. `lens`) that the builder uses.
1281
1222
  * - `buildContentMarker(epicId, finding)` — the content-hash HTML-comment
1282
1223
  * marker embedded in (and searched for in) follow-up bodies.
1283
1224
  * - `buildLegacyMarker(epicId, index)` — the pre-cutover ordinal marker,
@@ -1299,7 +1240,8 @@ async function persistCrossRepoDeferred({
1299
1240
  *
1300
1241
  * @param {object} opts
1301
1242
  * @param {number} opts.epicId
1302
- * @param {object} opts.provider — exposes `getTicketComments(ticketId)`
1243
+ * @param {object} opts.provider — exposes `postComment(ticketId, body)` for
1244
+ * the durable cross-repo-deferred persistence
1303
1245
  * @param {object} [opts.config]
1304
1246
  * @param {{owner: string, repo: string}} opts.currentRepo
1305
1247
  * @param {{owner: string, repo: string}} [opts.frameworkRepo]
@@ -1310,9 +1252,9 @@ async function persistCrossRepoDeferred({
1310
1252
  * @param {string} [opts.cwd]
1311
1253
  * @param {number} [opts.timeoutMs] — per-spawn watchdog bound
1312
1254
  * @param {number} [opts.maxFilingsPerRun] — per-run filing cap
1313
- * @param {Array<object>} [opts.findings] — pre-parsed findings; when
1314
- * provided, the structured-comment read/parse is bypassed (the retro
1315
- * auto-filer seam).
1255
+ * @param {Array<object>} opts.findings — the pre-parsed findings to file.
1256
+ * Required: a non-array is an `errors[]` short-circuit, never a silent
1257
+ * no-op.
1316
1258
  * @param {Set<string>} [opts.filedMarkers] — in-process memo of content
1317
1259
  * markers filed so far. Pass a shared Set across multiple `graduate()`
1318
1260
  * calls in one logical invocation (e.g. the retro graduator's two source
@@ -1360,23 +1302,23 @@ export async function graduate({
1360
1302
 
1361
1303
  const precondition = checkGraduatePreconditions({
1362
1304
  epicId,
1363
- provider,
1364
1305
  currentRepo,
1365
1306
  config,
1366
1307
  spec,
1367
1308
  });
1368
1309
  if (precondition) return { ...envelope, ...precondition };
1369
1310
 
1370
- let findings;
1371
- if (Array.isArray(preParsedFindings)) {
1372
- // Pre-parsed seam (retro auto-filer): bypass the structured-comment
1373
- // read + parse entirely and file the supplied findings directly.
1374
- findings = preParsedFindings;
1375
- } else {
1376
- const loaded = await loadGraduateFindings({ epicId, provider, spec });
1377
- if (!loaded.findings) return { ...envelope, ...loaded };
1378
- findings = loaded.findings;
1311
+ // Pre-parsed findings are the only source (Story #5003). The
1312
+ // structured-comment read/parse limb went with the audit-results
1313
+ // graduator: it walked an Epic's `verification-results` comment, and v2
1314
+ // has no Epics, so it could only ever resolve zero findings.
1315
+ if (!Array.isArray(preParsedFindings)) {
1316
+ return {
1317
+ ...envelope,
1318
+ errors: [`${spec.fnName}: findings[] is required and must be an array`],
1319
+ };
1379
1320
  }
1321
+ const findings = preParsedFindings;
1380
1322
 
1381
1323
  // Blast-radius guard (Story #4837): decided ONCE per walk, before the
1382
1324
  // first spawn, so a refused context costs no child process at all.
@@ -138,6 +138,14 @@ function metaSourceLabel(source) {
138
138
  * a single source so the constant classifier routes every finding in the
139
139
  * bucket to the correct repo (and the label reflects that source).
140
140
  *
141
+ * The bundle carries **only** builders. It used to declare a
142
+ * `commentMarker` / `noCommentReason` / `parseFindings` trio purely to satisfy
143
+ * the shared walk's shape — this graduator supplies its findings pre-parsed,
144
+ * so the walk never consulted any of the three. Story #5003 deleted that limb
145
+ * from `graduate()` along with the Epic-era graduator that was its only real
146
+ * consumer, and the placeholder fields went with it: a declared-but-unread
147
+ * field is a standing invitation to write a parser nothing will call.
148
+ *
141
149
  * @param {'framework'|'consumer'} source
142
150
  * @returns {object}
143
151
  */
@@ -145,12 +153,6 @@ function makeSpec(source) {
145
153
  return {
146
154
  fnName: 'graduateRetroProposals',
147
155
  isAutoFileEnabled,
148
- // Pre-parsed seam: the comment marker / parser are never consulted
149
- // (findings are supplied directly), but the fields are declared for the
150
- // shared walk's shape.
151
- commentMarker: '<!-- structured-comment: retro -->',
152
- noCommentReason: 'no-retro-comment',
153
- parseFindings: () => [],
154
156
  buildContentMarker,
155
157
  buildMatchTokens,
156
158
  crossRepoCommentAttrs: { graduator: 'retro-proposals' },
@@ -210,8 +212,8 @@ function toFinding(item, source, index) {
210
212
  *
211
213
  * @param {object} opts
212
214
  * @param {number} opts.epicId
213
- * @param {object} opts.provider — ticketing provider (getTicketComments;
214
- * postComment for the cross-repo-deferred persistence).
215
+ * @param {object} opts.provider — ticketing provider (`postComment`, for the
216
+ * cross-repo-deferred persistence).
215
217
  * @param {object} [opts.config] — resolved agentrc.
216
218
  * @param {{owner: string, repo: string}} opts.currentRepo — the repo the
217
219
  * retro is running inside (the consumer's own repo); the cross-repo guard's
@@ -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
+ }