mandrel 2.24.0 → 2.26.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 (189) hide show
  1. package/.agents/agents/acceptance-critic.md +19 -21
  2. package/.agents/agents/auditor.md +16 -19
  3. package/.agents/agents/story-worker.md +41 -54
  4. package/.agents/audit-checklists/architecture.md +5 -1
  5. package/.agents/audit-checklists/baselines.md +21 -0
  6. package/.agents/audit-checklists/quality.md +3 -0
  7. package/.agents/docs/agentrc-reference.json +2 -1
  8. package/.agents/docs/configuration.md +2 -1
  9. package/.agents/docs/quality-gates.md +80 -18
  10. package/.agents/docs/workflows.md +4 -2
  11. package/.agents/instructions.md +1 -1
  12. package/.agents/rules/ci-remediation.md +68 -3
  13. package/.agents/schemas/agentrc.schema.json +6 -1
  14. package/.agents/schemas/audit-rules.json +15 -0
  15. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  16. package/.agents/schemas/baselines/baseline-envelope.schema.json +8 -0
  17. package/.agents/schemas/baselines/crap.schema.json +13 -1
  18. package/.agents/schemas/crap-report.schema.json +37 -0
  19. package/.agents/schemas/model-attribution.schema.json +4 -0
  20. package/.agents/scripts/acceptance-eval.js +124 -15
  21. package/.agents/scripts/audit-baselines.js +136 -0
  22. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  23. package/.agents/scripts/audit-to-stories.js +33 -3
  24. package/.agents/scripts/check-arch-cycles.js +12 -93
  25. package/.agents/scripts/check-baseline-drift.js +16 -3
  26. package/.agents/scripts/check-baselines.js +19 -3
  27. package/.agents/scripts/check-context-budget.js +95 -14
  28. package/.agents/scripts/check-cyclomatic.js +214 -0
  29. package/.agents/scripts/check-schema-references.js +392 -0
  30. package/.agents/scripts/check-test-temp-hygiene.js +121 -1
  31. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  32. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  33. package/.agents/scripts/deliver-light.js +3 -4
  34. package/.agents/scripts/deliver-recover.js +13 -0
  35. package/.agents/scripts/diagnose-friction.js +85 -19
  36. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  37. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  38. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  39. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  40. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  41. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  42. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  43. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  44. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  45. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  46. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  47. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  48. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  49. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  50. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  51. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  52. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  53. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  54. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  55. package/.agents/scripts/lib/baseline-loader.js +0 -0
  56. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  57. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  58. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  59. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  60. package/.agents/scripts/lib/baselines/kinds/crap.js +499 -57
  61. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  62. package/.agents/scripts/lib/baselines/reader.js +10 -0
  63. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  64. package/.agents/scripts/lib/baselines/scope.js +39 -90
  65. package/.agents/scripts/lib/baselines/writer.js +16 -7
  66. package/.agents/scripts/lib/changed-files.js +8 -1
  67. package/.agents/scripts/lib/cli-args.js +115 -1
  68. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  69. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  70. package/.agents/scripts/lib/crap-engine.js +124 -27
  71. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  72. package/.agents/scripts/lib/crap-utils.js +86 -13
  73. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  74. package/.agents/scripts/lib/env-loader.js +46 -16
  75. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  76. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  77. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  78. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  79. package/.agents/scripts/lib/findings/severity.js +80 -2
  80. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  81. package/.agents/scripts/lib/git-utils.js +136 -80
  82. package/.agents/scripts/lib/import-graph.js +156 -0
  83. package/.agents/scripts/lib/label-constants.js +17 -0
  84. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  85. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  86. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  87. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  88. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  89. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  90. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  91. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  92. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  93. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  94. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  95. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  96. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  97. package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
  98. package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
  99. package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
  100. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  101. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  102. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +216 -109
  103. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +61 -13
  104. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  105. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  106. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  107. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  108. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  109. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  110. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  111. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  112. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  113. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +125 -37
  114. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  115. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  116. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  117. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  118. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +267 -125
  119. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  120. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  121. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  122. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  123. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  124. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  125. package/.agents/scripts/lib/story-adjacency.js +3 -3
  126. package/.agents/scripts/lib/story-plan.js +137 -42
  127. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  128. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  129. package/.agents/scripts/lib/test-tiers.js +11 -2
  130. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  131. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  132. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  133. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  134. package/.agents/scripts/lib/wave-runner/live-probe.js +29 -13
  135. package/.agents/scripts/lib/wave-runner/ready-set.js +254 -43
  136. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +10 -13
  137. package/.agents/scripts/lib/workers/crap-worker.js +8 -13
  138. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  139. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  140. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  141. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  142. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  143. package/.agents/scripts/lib/worktree-manager.js +14 -0
  144. package/.agents/scripts/plan-run-epilogue.js +17 -5
  145. package/.agents/scripts/pr-watch-with-update.js +568 -213
  146. package/.agents/scripts/providers/github/tickets.js +33 -10
  147. package/.agents/scripts/provision-git-hooks.js +85 -0
  148. package/.agents/scripts/quality-preview.js +137 -28
  149. package/.agents/scripts/resolve-stories.js +4 -1
  150. package/.agents/scripts/run-coverage.js +86 -35
  151. package/.agents/scripts/run-lint.js +20 -0
  152. package/.agents/scripts/run-tests.js +36 -36
  153. package/.agents/scripts/single-story-close.js +34 -2
  154. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  155. package/.agents/scripts/single-story-init.js +7 -0
  156. package/.agents/scripts/stories-wave-tick.js +308 -47
  157. package/.agents/scripts/story-plan.js +65 -9
  158. package/.agents/scripts/update-coverage-baseline.js +34 -4
  159. package/.agents/scripts/update-crap-baseline.js +42 -4
  160. package/.agents/scripts/update-duplication-baseline.js +209 -83
  161. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  162. package/.agents/scripts/validate-docs-freshness.js +1 -0
  163. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  164. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  165. package/.agents/skills/skills.index.json +2 -2
  166. package/.agents/templates/single-story-body.md +16 -8
  167. package/.agents/workflows/audit-architecture.md +69 -13
  168. package/.agents/workflows/audit-baselines.md +289 -0
  169. package/.agents/workflows/audit-navigability.md +5 -4
  170. package/.agents/workflows/audit-quality.md +26 -0
  171. package/.agents/workflows/audit-to-stories.md +30 -4
  172. package/.agents/workflows/deliver.md +92 -97
  173. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  174. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  175. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  176. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  177. package/.agents/workflows/helpers/deliver-reference.md +95 -4
  178. package/.agents/workflows/helpers/deliver-story-reference.md +174 -52
  179. package/.agents/workflows/helpers/deliver-story.md +104 -119
  180. package/.agents/workflows/helpers/plan-reference.md +49 -5
  181. package/.agents/workflows/memory-consolidate.md +116 -0
  182. package/.agents/workflows/plan.md +62 -75
  183. package/README.md +13 -6
  184. package/docs/CHANGELOG.md +93 -0
  185. package/lib/cli/update.js +14 -11
  186. package/lib/cli/version-check.js +9 -1
  187. package/package.json +9 -4
  188. package/.agents/schemas/friction-event.schema.json +0 -56
  189. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -0,0 +1,392 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * CLI: report every JSON Schema under `.agents/schemas/` that no code path
5
+ * compiles.
6
+ *
7
+ * A well-formed schema sitting in a schema directory reads as an enforced
8
+ * contract to every human reader and every audit lens — existence plus valid
9
+ * syntax is taken for authority. When nothing actually compiles it, that
10
+ * reading is wrong in the most expensive direction: the reader believes a
11
+ * shape the runtime rejects. `friction-event.schema.json` sat unreferenced
12
+ * from the Epic #4406 envelope cutover until Story #4938; in between, an
13
+ * `/audit-documentation` lens graded a High finding against it as the live
14
+ * contract, the finding became an acceptance criterion, and the delivering
15
+ * worker had to overrule that criterion to avoid writing a payload the
16
+ * enforced validator drops. Two verification layers accepted the file
17
+ * because it existed and parsed. Only reading the code caught it.
18
+ *
19
+ * This gate is that code read, run every time instead of once by accident.
20
+ *
21
+ * ## What counts as a compiling reference
22
+ *
23
+ * Resolution is evidence-ranked; the first kind that hits wins, and the
24
+ * report names both the kind and the file that supplied the evidence, so a
25
+ * pass is auditable rather than merely green.
26
+ *
27
+ * 1. `literal` — the schema's basename appears in a JS source. This is
28
+ * the ordinary idiom (`signal-validator.js` resolves
29
+ * `signal-event.schema.json` by literal path).
30
+ * 2. `schema-ref` — another schema `$ref`s this file by name, and that
31
+ * schema is itself referenced. A `$ref` is a compile
32
+ * edge: AJV pulls the target in when the parent
33
+ * compiles.
34
+ * 3. `dynamic` — the schema lives in a SUBDIRECTORY of the schema root
35
+ * that some JS source loads by computed path (a
36
+ * `` `${key}.schema.json` `` template), AND the
37
+ * schema's stem appears literally in a JS source as the
38
+ * key that would produce it. `lifecycle/` is loaded this
39
+ * way by `lib/orchestration/lifecycle/bus.js`, and
40
+ * `baselines/` by `lib/baselines/envelope.js`.
41
+ *
42
+ * BOTH halves are required. The directory evidence alone
43
+ * would bless every future file dropped into a
44
+ * dynamically-enumerated directory — the same blindness
45
+ * this gate exists to remove. Dynamic resolution is not
46
+ * offered for schemas sitting at the schema root: every
47
+ * root schema today is compiled by an explicit literal
48
+ * path, so a root-level stem match would be coincidence,
49
+ * not evidence.
50
+ *
51
+ * Anything left over is a finding — unless the schema says otherwise in its
52
+ * own body (see below). A finding is not an accusation of deadness; it is a
53
+ * demand that the file state which one it is.
54
+ *
55
+ * ## Recording a deliberate exemption
56
+ *
57
+ * A schema deliberately kept without a compiler declares it IN THE FILE, at
58
+ * the document root:
59
+ *
60
+ * ```json
61
+ * "x-mandrel-uncompiled": {
62
+ * "reason": "Documented SSOT; AJV is deliberately not in this path.",
63
+ * "runtimeGate": "lib/orchestration/foo.js#validateFooPayload"
64
+ * }
65
+ * ```
66
+ *
67
+ * In-file, never a side-car allowlist. The failure this gate answers was a
68
+ * reader trusting the schema document; the correction has to be legible to
69
+ * that same reader, in that same document. An allowlist elsewhere in the repo
70
+ * would leave the file itself still lying.
71
+ *
72
+ * Story #4938 left the tree with exactly one: `friction-event.schema.json` was
73
+ * deleted outright (its shape preserved field-for-field in
74
+ * `docs/archive/data-dictionary-2026-08.md`), and
75
+ * `model-attribution.schema.json` — a documented SSOT with a hand-rolled
76
+ * validator behind it — declared the marker.
77
+ *
78
+ * The gate answers "is anything compiling this?", not "is what compiles it
79
+ * faithful to it?". A schema whose hand-rolled mirror has silently drifted
80
+ * still passes here.
81
+ *
82
+ * Flags:
83
+ * --root <path> scan a different repository root (default: cwd)
84
+ * --json emit the structured envelope as single-line JSON
85
+ */
86
+
87
+ import fs from 'node:fs';
88
+ import path from 'node:path';
89
+ import process from 'node:process';
90
+ import { runAsCli } from './lib/cli-utils.js';
91
+
92
+ /** Schema root, relative to the repository root. */
93
+ const SCHEMA_ROOT = path.join('.agents', 'schemas');
94
+
95
+ /** Directories never worth walking for JS sources. */
96
+ const SKIP_DIRS = new Set([
97
+ '.git',
98
+ '.worktrees',
99
+ 'node_modules',
100
+ 'coverage',
101
+ 'temp',
102
+ ]);
103
+
104
+ /** Source extensions that can compile a schema. */
105
+ const JS_EXTENSIONS = new Set(['.js', '.mjs', '.cjs']);
106
+
107
+ /** Root keyword a schema uses to declare a deliberate exemption. */
108
+ const EXEMPTION_KEY = 'x-mandrel-uncompiled';
109
+
110
+ /**
111
+ * A computed schema path — `` `${something}.schema.json` `` — anywhere in a
112
+ * source file. Evidence that the file loads schemas by runtime key rather
113
+ * than by literal name.
114
+ */
115
+ const COMPUTED_PATH_RE = /\$\{[^}]*\}\.schema\.json/;
116
+
117
+ /** Block comments, including the JSDoc prose every module here opens with. */
118
+ const BLOCK_COMMENT_RE = /\/\*[\s\S]*?\*\//g;
119
+
120
+ /**
121
+ * Line comments. Anchored to start-of-line or whitespace so a `//` inside a
122
+ * URL (`http://…`) is left alone — truncating that line could delete a real
123
+ * reference sitting after it.
124
+ */
125
+ const LINE_COMMENT_RE = /(^|\s)\/\/[^\n]*/g;
126
+
127
+ /**
128
+ * Strip comments from a source before matching.
129
+ *
130
+ * Prose is not a compile. This module's own header names
131
+ * `friction-event.schema.json` a dozen times while compiling nothing, and
132
+ * every schema-loading module documents the path it loads directly above the
133
+ * line that loads it — matching raw text would let a gate certify itself and
134
+ * would count a docblock as enforcement, which is precisely the confusion
135
+ * Story #4938 exists to end.
136
+ *
137
+ * @param {string} text raw source
138
+ * @returns {string} source with comment bodies removed
139
+ */
140
+ function stripComments(text) {
141
+ return text.replace(BLOCK_COMMENT_RE, ' ').replace(LINE_COMMENT_RE, '$1');
142
+ }
143
+
144
+ /**
145
+ * Parse argv. Exported so a unit test can pin the parser without spawning.
146
+ *
147
+ * @param {string[]} argv
148
+ * @returns {{ root: string, json: boolean }}
149
+ */
150
+ export function parseArgv(argv = []) {
151
+ const out = { root: process.cwd(), json: false };
152
+ for (let i = 0; i < argv.length; i += 1) {
153
+ if (argv[i] === '--json') out.json = true;
154
+ else if (argv[i] === '--root' && argv[i + 1]) {
155
+ out.root = path.resolve(argv[i + 1]);
156
+ i += 1;
157
+ }
158
+ }
159
+ return out;
160
+ }
161
+
162
+ /**
163
+ * Recursively collect files under `dir` whose extension is in `extensions`.
164
+ *
165
+ * @param {string} dir absolute directory to walk
166
+ * @param {Set<string>} extensions lowercase extensions including the dot
167
+ * @param {string[]} [acc]
168
+ * @returns {string[]} absolute file paths
169
+ */
170
+ function walk(dir, extensions, acc = []) {
171
+ let entries;
172
+ try {
173
+ entries = fs.readdirSync(dir, { withFileTypes: true });
174
+ } catch {
175
+ return acc;
176
+ }
177
+ for (const entry of entries) {
178
+ const full = path.join(dir, entry.name);
179
+ if (entry.isDirectory()) {
180
+ if (!SKIP_DIRS.has(entry.name)) walk(full, extensions, acc);
181
+ } else if (extensions.has(path.extname(entry.name).toLowerCase())) {
182
+ acc.push(full);
183
+ }
184
+ }
185
+ return acc;
186
+ }
187
+
188
+ /**
189
+ * Read every JS source in the repository once.
190
+ *
191
+ * @param {string} root absolute repository root
192
+ * @returns {Array<{ rel: string, text: string }>}
193
+ */
194
+ function readSources(root) {
195
+ return walk(root, JS_EXTENSIONS).map((file) => ({
196
+ rel: path.relative(root, file),
197
+ text: stripComments(fs.readFileSync(file, 'utf8')),
198
+ }));
199
+ }
200
+
201
+ /**
202
+ * Describe every `*.schema.json` under the schema root.
203
+ *
204
+ * @param {string} root absolute repository root
205
+ * @returns {Array<{ rel: string, basename: string, stem: string, dir: string, doc: object|null }>}
206
+ */
207
+ function readSchemas(root) {
208
+ const schemaRoot = path.join(root, SCHEMA_ROOT);
209
+ return walk(schemaRoot, new Set(['.json']))
210
+ .filter((file) => file.endsWith('.schema.json'))
211
+ .map((file) => {
212
+ const basename = path.basename(file);
213
+ let doc = null;
214
+ try {
215
+ doc = JSON.parse(fs.readFileSync(file, 'utf8'));
216
+ } catch {
217
+ doc = null;
218
+ }
219
+ return {
220
+ rel: path.relative(root, file),
221
+ basename,
222
+ stem: basename.slice(0, -'.schema.json'.length),
223
+ dir: path.relative(schemaRoot, path.dirname(file)),
224
+ doc,
225
+ };
226
+ })
227
+ .sort((a, b) => a.rel.localeCompare(b.rel));
228
+ }
229
+
230
+ /**
231
+ * Find the first source whose text contains `needle`.
232
+ *
233
+ * @param {Array<{ rel: string, text: string }>} sources
234
+ * @param {string} needle
235
+ * @returns {string | null} the relative path of the witness, or null
236
+ */
237
+ function witness(sources, needle) {
238
+ const hit = sources.find((source) => source.text.includes(needle));
239
+ return hit ? hit.rel : null;
240
+ }
241
+
242
+ /**
243
+ * Find a source that loads schemas from `dir` by computed path.
244
+ *
245
+ * The file must both build a runtime-keyed schema path and name the
246
+ * directory — as a quoted `path.join` segment or as a `schemas/<dir>` path
247
+ * fragment.
248
+ *
249
+ * @param {Array<{ rel: string, text: string }>} sources
250
+ * @param {string} dir directory name relative to the schema root
251
+ * @returns {string | null}
252
+ */
253
+ function dynamicLoaderFor(sources, dir) {
254
+ if (!dir) return null;
255
+ const hit = sources.find(
256
+ (source) =>
257
+ COMPUTED_PATH_RE.test(source.text) &&
258
+ (source.text.includes(`'${dir}'`) ||
259
+ source.text.includes(`"${dir}"`) ||
260
+ source.text.includes(`schemas/${dir}`)),
261
+ );
262
+ return hit ? hit.rel : null;
263
+ }
264
+
265
+ /**
266
+ * Read a schema's in-file exemption declaration.
267
+ *
268
+ * A bare string is accepted as the reason; the object form additionally
269
+ * carries `runtimeGate`, the `file.js#functionName` actually enforcing the
270
+ * shape at runtime. That field is what makes an exemption reviewable: a
271
+ * schema kept as a documented SSOT with a hand-rolled validator behind it is
272
+ * a different animal from one kept purely for provenance, and the report
273
+ * should not flatten the two.
274
+ *
275
+ * @param {object|null} doc parsed schema document
276
+ * @returns {{ reason: string, runtimeGate: string | null } | null}
277
+ */
278
+ function exemption(doc) {
279
+ const block = doc?.[EXEMPTION_KEY];
280
+ const reason = typeof block === 'string' ? block : block?.reason;
281
+ if (typeof reason !== 'string' || !reason.trim()) return null;
282
+ const gate =
283
+ typeof block?.runtimeGate === 'string' ? block.runtimeGate : null;
284
+ return { reason: reason.trim(), runtimeGate: gate };
285
+ }
286
+
287
+ /**
288
+ * Resolve one schema to a reference kind plus the evidence for it.
289
+ *
290
+ * @param {object} schema a `readSchemas` record
291
+ * @param {Array<{ rel: string, text: string }>} sources
292
+ * @param {Array<{ rel: string, text: string }>} schemaTexts raw schema bodies
293
+ * @returns {{ kind: string, evidence: string | object | null }}
294
+ */
295
+ function resolveSchema(schema, sources, schemaTexts) {
296
+ const literal = witness(sources, schema.basename);
297
+ if (literal) return { kind: 'literal', evidence: literal };
298
+
299
+ const ref = schemaTexts.find(
300
+ (other) =>
301
+ other.rel !== schema.rel &&
302
+ other.text.includes(`"$ref"`) &&
303
+ other.text.includes(schema.basename),
304
+ );
305
+ if (ref) return { kind: 'schema-ref', evidence: ref.rel };
306
+
307
+ const loader = dynamicLoaderFor(sources, schema.dir);
308
+ if (loader) {
309
+ const key = witness(sources, schema.stem);
310
+ if (key) return { kind: 'dynamic', evidence: `${loader} (key: ${key})` };
311
+ }
312
+
313
+ const declared = exemption(schema.doc);
314
+ if (declared) return { kind: 'exempt', evidence: declared };
315
+
316
+ return { kind: 'unreferenced', evidence: null };
317
+ }
318
+
319
+ /**
320
+ * Run the audit and return a structured envelope.
321
+ *
322
+ * @param {{ root?: string }} [opts]
323
+ * @returns {{ schemaCount: number, referenced: number, exempt: Array<object>, findings: Array<object> }}
324
+ */
325
+ export function auditSchemaReferences(opts = {}) {
326
+ const root = opts.root ?? process.cwd();
327
+ const sources = readSources(root);
328
+ const schemas = readSchemas(root);
329
+ const schemaTexts = schemas.map((schema) => ({
330
+ rel: schema.rel,
331
+ text: fs.readFileSync(path.join(root, schema.rel), 'utf8'),
332
+ }));
333
+
334
+ const exempt = [];
335
+ const findings = [];
336
+ let referenced = 0;
337
+
338
+ for (const schema of schemas) {
339
+ const { kind, evidence } = resolveSchema(schema, sources, schemaTexts);
340
+ if (kind === 'unreferenced') findings.push({ schema: schema.rel });
341
+ else if (kind === 'exempt')
342
+ exempt.push({ schema: schema.rel, ...evidence });
343
+ else referenced += 1;
344
+ }
345
+
346
+ return { schemaCount: schemas.length, referenced, exempt, findings };
347
+ }
348
+
349
+ /**
350
+ * Render the human-readable report.
351
+ *
352
+ * @param {ReturnType<typeof auditSchemaReferences>} report
353
+ * @returns {string}
354
+ */
355
+ function formatReport(report) {
356
+ const digest =
357
+ `[check-schema-references] ${report.schemaCount} schema(s): ` +
358
+ `${report.referenced} compiled, ${report.exempt.length} exempt, ` +
359
+ `${report.findings.length} unreferenced.`;
360
+ if (report.findings.length === 0) return digest;
361
+ const lines = report.findings.map(
362
+ (finding) => ` ✗ ${finding.schema} — no code path compiles it.`,
363
+ );
364
+ return [
365
+ digest,
366
+ ...lines,
367
+ '',
368
+ 'Every schema under .agents/schemas/ must be compiled by code, or say in',
369
+ `its own body why not. Either delete it, wire it, or add a root-level`,
370
+ `"${EXEMPTION_KEY}": { "reason": "…" } declaring the retention.`,
371
+ ].join('\n');
372
+ }
373
+
374
+ /**
375
+ * CLI entry point.
376
+ *
377
+ * @param {string[]} [argv]
378
+ * @returns {Promise<number>} process exit code
379
+ */
380
+ export async function main(argv = process.argv.slice(2)) {
381
+ const { root, json } = parseArgv(argv);
382
+ const report = auditSchemaReferences({ root });
383
+ process.stdout.write(
384
+ json ? `${JSON.stringify(report)}\n` : `${formatReport(report)}\n`,
385
+ );
386
+ return report.findings.length > 0 ? 1 : 0;
387
+ }
388
+
389
+ runAsCli(import.meta.url, main, {
390
+ source: 'check-schema-references',
391
+ propagateExitCode: true,
392
+ });
@@ -78,6 +78,11 @@ import os from 'node:os';
78
78
  import path from 'node:path';
79
79
  import { fileURLToPath } from 'node:url';
80
80
  import { runAsCli } from './lib/cli-utils.js';
81
+ import { mainCheckoutRoot } from './lib/config/temp-paths.js';
82
+ import {
83
+ isReservedTestId,
84
+ RESERVED_TEST_ID_BAND,
85
+ } from './lib/reserved-test-ids.js';
81
86
  import {
82
87
  findRawTmpdirMkdtemp,
83
88
  listSuiteTempRoots,
@@ -369,6 +374,84 @@ export function cleanFixtureDirs({
369
374
  return { candidates, removed };
370
375
  }
371
376
 
377
+ /**
378
+ * Every Epic / Story id a stream file's own path attributes it to.
379
+ *
380
+ * Both canonical layouts are read, and a nested Epic-attached stream yields
381
+ * both ids (`run-<eid>/stories/story-<sid>/…`): either half being a fixture id
382
+ * makes the stream fixture-owned, and taking only the outer one is how a
383
+ * fixture Story under a real run would slip past.
384
+ *
385
+ * @param {string} rel POSIX-normalised path relative to `temp/`.
386
+ * @returns {number[]}
387
+ */
388
+ function streamOwnerIds(rel) {
389
+ const ids = [];
390
+ const run = /^run-(\d+)$/.exec(rel.split('/')[0]);
391
+ if (run) ids.push(Number(run[1]));
392
+ const story = /(?:^|\/)story-(\d+)\//.exec(rel);
393
+ if (story) ids.push(Number(story[1]));
394
+ return ids;
395
+ }
396
+
397
+ /**
398
+ * Stream files under `tempDir` owned by a **reserved test-fixture id**
399
+ * (Story #4892).
400
+ *
401
+ * This is the residual-pollution dimension the snapshot/assert bracket cannot
402
+ * cover: the bracket only runs in CI, where `temp/` starts empty, so a local
403
+ * run that appends fixture telemetry to the operator's live ledger was only
404
+ * ever discovered from the ticket the retro graduator filed off it (issue
405
+ * #4870 cited `#999999`, a `--story 999999` CLI spawn from the suite).
406
+ *
407
+ * Unlike the snapshot diff this needs no baseline and is immune to a
408
+ * concurrent delivery in another checkout: a reserved id is reserved *from*
409
+ * real work, so nothing but a test can own one of these files.
410
+ *
411
+ * @param {string} tempDir
412
+ * @returns {string[]} POSIX-normalised paths relative to `tempDir`, sorted.
413
+ */
414
+ export function findReservedIdStreamFiles(tempDir) {
415
+ return listStreamFiles(tempDir).filter((rel) =>
416
+ streamOwnerIds(rel).some(isReservedTestId),
417
+ );
418
+ }
419
+
420
+ /**
421
+ * Post-run guard: fail when a test run left a fixture-id telemetry stream in
422
+ * the **repository-root** temp tree (Story #4892).
423
+ *
424
+ * Resolution matters more than it looks: every writer anchors a relative
425
+ * `tempRoot` to the *main checkout* (so a Story worktree and its `/deliver`
426
+ * host converge on one ledger), so a guard that scanned `cwd` would scan an
427
+ * empty worktree tree and pass vacuously on the very tree it is meant to
428
+ * protect. `resolveRoot` is the injection seam for tests.
429
+ *
430
+ * @param {object} [opts]
431
+ * @param {string} [opts.cwd=process.cwd()]
432
+ * @param {(line: string) => void} [opts.log]
433
+ * @param {(cwd: string) => string|null} [opts.resolveRoot]
434
+ * @returns {number} exit code (0 clean, 1 polluted).
435
+ */
436
+ export function assertNoReservedIdStreams({
437
+ cwd = process.cwd(),
438
+ log = (l) => process.stderr.write(`${l}\n`),
439
+ resolveRoot = mainCheckoutRoot,
440
+ } = {}) {
441
+ const root = resolveRoot(cwd) ?? cwd;
442
+ const tempDir = tempDirFor(root);
443
+ const found = findReservedIdStreamFiles(tempDir);
444
+ if (found.length === 0) return 0;
445
+ log(
446
+ `[test-temp-hygiene] FAIL — ${found.length} fixture-id telemetry stream(s) in the real temp tree (${tempDir}):`,
447
+ );
448
+ for (const rel of found) log(` + fixture ${rel}`);
449
+ log(
450
+ `[test-temp-hygiene] ids ${RESERVED_TEST_ID_BAND} are reserved for fixtures, so a test wrote to the live ledger — the retro graduator reads these streams and files tickets off them. Inject an absolute per-test tempRoot on the offending spawn, then remove the stream(s) with --clean --ids <id> --yes.`,
451
+ );
452
+ return 1;
453
+ }
454
+
372
455
  /**
373
456
  * Parse the CLI argv into a normalised options object.
374
457
  * @param {string[]} argv
@@ -573,5 +656,42 @@ runAsCli(
573
656
  const code = runHygiene(parseArgv(process.argv.slice(2)));
574
657
  return code;
575
658
  },
576
- { source: 'check-test-temp-hygiene', propagateExitCode: true },
659
+ {
660
+ source: 'check-test-temp-hygiene',
661
+ propagateExitCode: true,
662
+ usage: {
663
+ invocation:
664
+ 'node .agents/scripts/check-test-temp-hygiene.js [--snapshot | --assert | --clean] [--baseline <path>] [--lint-globs <globs>] [--ids <ids>] [--root <dir>]',
665
+ summary:
666
+ 'Regression guard for test-fixture pollution of the temp trees: the repo temp/ telemetry streams every retro reads, and the OS temp root the suite scratch dirs nest under.',
667
+ flags: [
668
+ [
669
+ '--snapshot',
670
+ 'Fingerprint every temp/ stream file and the pre-existing OS suite roots. Run BEFORE the suite.',
671
+ ],
672
+ [
673
+ '--assert',
674
+ 'Re-scan and fail on any stream added or grown, or a surviving suite root. Run AFTER the suite. This is the default when no mode flag is passed.',
675
+ ],
676
+ [
677
+ '--clean',
678
+ 'List stream directories whose Epic/Story id matches a known fixture id. Report-only — deletes nothing.',
679
+ ],
680
+ [
681
+ '--baseline <path>',
682
+ 'Explicit snapshot path (CI sets a runner-temp path). Defaults to an OS scratch location keyed by the repo root; refused inside the protected temp/ tree.',
683
+ ],
684
+ [
685
+ '--lint-globs <globs>',
686
+ 'Comma-separated repo-relative globs scanned for tests calling mkdtemp against os.tmpdir() instead of makeTempDir(). Off unless passed.',
687
+ ],
688
+ ['--ids <ids>', 'Comma-separated fixture ids for --clean.'],
689
+ ['--root <dir>', 'Repository root to scan (default: repo root).'],
690
+ ],
691
+ notes: [
692
+ '--assert requires a snapshot recorded by an earlier --snapshot run: a missing one is a hard failure, never a silent re-baseline.',
693
+ 'Exit codes:\n 0 clean\n 1 a breach, or --assert with no snapshot to attest against',
694
+ ],
695
+ },
696
+ },
577
697
  );