mandrel 2.16.0 → 2.18.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 (69) hide show
  1. package/.agents/docs/agentrc-reference.json +10 -0
  2. package/.agents/docs/configuration.md +9 -0
  3. package/.agents/docs/quality-gates.md +137 -0
  4. package/.agents/schemas/agentrc.schema.json +48 -0
  5. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  6. package/.agents/schemas/baselines/crap.schema.json +4 -0
  7. package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
  8. package/.agents/scripts/acceptance-eval.js +52 -12
  9. package/.agents/scripts/audit-to-stories.js +92 -25
  10. package/.agents/scripts/boot-sweep.js +67 -8
  11. package/.agents/scripts/check-baseline-drift.js +138 -0
  12. package/.agents/scripts/coverage-capture.js +74 -25
  13. package/.agents/scripts/deliver-recover.js +45 -18
  14. package/.agents/scripts/drain-pending-cleanup.js +67 -23
  15. package/.agents/scripts/generate-lens-checklists.js +81 -30
  16. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
  17. package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
  18. package/.agents/scripts/lib/baselines/envelope.js +7 -0
  19. package/.agents/scripts/lib/baselines/kernel.js +31 -0
  20. package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
  21. package/.agents/scripts/lib/baselines/reader.js +12 -1
  22. package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
  23. package/.agents/scripts/lib/baselines/writer.js +10 -0
  24. package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
  25. package/.agents/scripts/lib/cli-utils.js +48 -13
  26. package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
  27. package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
  28. package/.agents/scripts/lib/close-validation/runner.js +68 -0
  29. package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
  30. package/.agents/scripts/lib/config/quality.js +40 -0
  31. package/.agents/scripts/lib/config/temp-paths.js +27 -0
  32. package/.agents/scripts/lib/config-settings-schema-delivery.js +69 -0
  33. package/.agents/scripts/lib/coverage-utils.js +92 -9
  34. package/.agents/scripts/lib/crap-engine.js +113 -23
  35. package/.agents/scripts/lib/crap-utils.js +159 -93
  36. package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
  37. package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
  38. package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
  39. package/.agents/scripts/lib/observability/terse-result.js +7 -3
  40. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
  41. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
  42. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
  43. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +19 -41
  44. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
  45. package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +9 -5
  46. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +15 -1
  47. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +31 -1
  48. package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
  49. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
  50. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
  51. package/.agents/scripts/lib/single-story-sweep.js +11 -0
  52. package/.agents/scripts/lib/stdio-flush.js +71 -0
  53. package/.agents/scripts/lib/temp-retention.js +559 -0
  54. package/.agents/scripts/lib/transpile.js +133 -6
  55. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
  56. package/.agents/scripts/lib/workers/crap-worker.js +49 -76
  57. package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
  58. package/.agents/scripts/nav-registry-diff.js +30 -8
  59. package/.agents/scripts/plan-run-epilogue.js +27 -11
  60. package/.agents/scripts/resolve-doc-tiers.js +18 -8
  61. package/.agents/scripts/single-story-close.js +9 -92
  62. package/.agents/scripts/single-story-init.js +1 -1
  63. package/.agents/scripts/sync-branch-from-base.js +6 -1
  64. package/.agents/scripts/update-crap-baseline.js +13 -0
  65. package/README.md +14 -6
  66. package/docs/CHANGELOG.md +36 -0
  67. package/lib/cli/version-helpers.js +7 -0
  68. package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
  69. package/package.json +5 -1
@@ -128,6 +128,16 @@
128
128
  "docsFreshness": {
129
129
  "paths": ["README.md"]
130
130
  },
131
+ "tempRetention": {
132
+ "enabled": true,
133
+ "staleDays": 7,
134
+ "classes": {
135
+ "orchestrationLogs": true,
136
+ "validationEvidence": true,
137
+ "auditResults": true,
138
+ "planDirs": true
139
+ }
140
+ },
131
141
  "deliverRunner": {
132
142
  "concurrencyCap": 3
133
143
  },
@@ -125,6 +125,14 @@ top-level keys are validation errors.
125
125
  | `lease.ttlMs` | No | `integer` | — | — |
126
126
  | `docsFreshness` | No | `object` | — | Nested configuration block. |
127
127
  | `docsFreshness.paths` | No | `array` | — | — |
128
+ | `tempRetention` | No | `object` | — | Story #4794. Auto-purge of spent temp artifacts once their Story lands. Classification is an allowlist: only the declared classes below are ever deleted, so operator scratch files under tempRoot are reported with their size and left alone. signals.ndjson is never purged by any path. |
129
+ | `tempRetention.enabled` | No | `boolean` | — | Master switch. Default true — reclaiming a landed Story's gate transcripts and validation evidence is the behaviour, and this knob turns it off. When false every purge path is a reported no-op. |
130
+ | `tempRetention.staleDays` | No | `integer` | — | Age floor (days, default 7) for the families no Story id can be recovered from — roster-level audit reports and abandoned plan-<slug>/ dirs. Story-keyed artifacts do not wait for it: they are purged as soon as their merge is confirmed. |
131
+ | `tempRetention.classes` | No | `object` | — | Per-class opt-out. Each defaults to true; set one false to keep that family while the rest are purged. |
132
+ | `tempRetention.classes.orchestrationLogs` | No | `boolean` | — | <tempRoot>/orchestration/*.log — close gate transcripts and terse-result detail dumps. |
133
+ | `tempRetention.classes.validationEvidence` | No | `boolean` | — | Per-Story validation-evidence.json, lifecycle.ndjson, and manifest.md under the standalone and per-run story trees. |
134
+ | `tempRetention.classes.auditResults` | No | `boolean` | — | <tempRoot>/audits/ — audit lens reports. |
135
+ | `tempRetention.classes.planDirs` | No | `boolean` | — | <tempRoot>/plan-<slug>/ — abandoned plan authoring dirs. Age-floored only; the current run is always excluded. |
128
136
  | `deliverRunner` | No | `object` | — | Nested configuration block. |
129
137
  | `deliverRunner.concurrencyCap` | No | `integer` | — | 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. |
130
138
  | `worktreeIsolation` | No | `object` | — | Nested configuration block. |
@@ -174,6 +182,7 @@ top-level keys are validation errors.
174
182
  | `quality.gates.crap.targetDirs` | No | `string[]` or `{ append?, prepend? }` | — | Directories whose JS sources the CRAP gate scores. Mandrel ships a `src/`-centric default; projects whose executable code lives elsewhere (e.g. this repo's `.agents/scripts/`) override here. The framework default is intentionally not auto-discovered, so an override is the explicit, auditable signal. |
175
183
  | `quality.gates.crap.newMethodCeiling` | No | `integer` | — | — |
176
184
  | `quality.gates.crap.requireCoverage` | No | `boolean` | — | — |
185
+ | `quality.gates.crap.minMethodResolutionRate` | No | `number` | — | Fail-closed floor on the per-method coverage JOIN (Story #4775): the fraction of methods that must resolve a coverage entry, counted only over files that HAVE one, before `update-crap-baseline.js` will persist. A broken join is silent by construction — unresolved methods are simply absent — so the updater refuses rather than writing a thin baseline and logging it as success. Not enforced below 25 joinable methods, where a diff-scoped run's rate is noise. Default 0.75; a healthy repo resolves ~98%. |
177
186
  | `quality.gates.crap.friction` | No | `object` | — | Nested configuration block. |
178
187
  | `quality.gates.crap.friction.markerKey` | No | `string` | — | — |
179
188
  | `quality.gates.crap.refreshTag` | No | `string` | — | — |
@@ -361,6 +361,143 @@ There is no CI guardrail rejecting unlabeled baseline edits; the convention is
361
361
  preserved so the operator can grep refresh commits in a PR diff, but
362
362
  self-policing is the operator's job during `/deliver`'s watch loop.
363
363
 
364
+ ### The per-method coverage join (Story #4775)
365
+
366
+ CRAP is the only gate that joins two independently-produced artifacts: the
367
+ per-method complexity escomplex derives from the source, and the per-function
368
+ coverage istanbul derives from the test run. Everything below exists because
369
+ that join is silent when it fails — an unresolved method is simply absent from
370
+ the baseline, so a broken join looks exactly like a small repo.
371
+
372
+ **One coordinate system.** For a TS/TSX source, escomplex parses the
373
+ *transpiled* output and reports each method's `lineStart` in transpiled
374
+ coordinates, while `coverage-final.json` is keyed against the *original*
375
+ source. The scorer therefore asks `transpileIfNeeded` for a source map
376
+ (`{ withLineMap: true }`, backed by Node's built-in `SourceMap` — no extra
377
+ runtime dependency) and remaps each method start into original coordinates
378
+ before the lookup. JavaScript is a passthrough: its coordinates already are
379
+ original coordinates, so no map is computed and nothing changes. The
380
+ maintainability path never requests a map, and the emitted code is
381
+ byte-identical either way, so MI scores are unaffected.
382
+
383
+ **Tolerant matching.** Remapping alone is insufficient: escomplex's method
384
+ start and istanbul's `decl.start.line` disagree by a line when a decorator, a
385
+ leading `export`, or a wrapped parameter list sits between them. The lookup is
386
+ exact-line first (so every already-resolving row keeps its exact prior value),
387
+ then innermost containment, then nearest declaration within ±1.
388
+
389
+ **`requireCoverage: false` means score it.** A method with no coverage entry
390
+ scores as 0% covered — `crap = c² + c`, the formula's own treatment of
391
+ untested code — and lands in the baseline. It used to be dropped individually
392
+ regardless of the flag, which made the flag a no-op for baseline population.
393
+ `requireCoverage: true` still skips and counts it.
394
+
395
+ **The updater fails closed on a thin result.** `update-crap-baseline.js`
396
+ reports `resolved/joinable` over files that *have* coverage and refuses to
397
+ persist below `delivery.quality.gates.crap.minMethodResolutionRate` (default
398
+ `0.75`), naming the worst unresolved files. The floor is not enforced below 25
399
+ joinable methods, where a diff-scoped run's rate is noise. A healthy repo
400
+ resolves ~98%; the 4–6% signature of a coordinate-system mismatch is far below
401
+ the floor.
402
+
403
+ **Re-derive your floors after adopting this — but do not re-pin `max`.** A
404
+ `crap.floors` `max` ceiling pinned before the fix was computed over the
405
+ minority of methods the join could see, so it is not a real ceiling — it is an
406
+ artefact. The honest scan sees far more (in this repository, 2215 → 4058
407
+ visible methods), and the newly-visible methods include the worst ones.
408
+
409
+ The tempting response — raise `*.max` until the gate is green again — produces a
410
+ floor fitted to the tree's current high-water mark, which **can never fire**:
411
+ nothing breaches it until something becomes worse than the worst method already
412
+ present. Prefer a *count* budget over a max ceiling:
413
+
414
+ ```jsonc
415
+ "crap": {
416
+ // Number of methods allowed to score above 20. Ratchet this down; it
417
+ // breaches the moment the count grows, which a `max` ceiling cannot do.
418
+ "floors": { "*": { "methodsAbove20": 40 } }
419
+ }
420
+ ```
421
+
422
+ `max` remains available and is the right instrument when you genuinely have a
423
+ hard per-method ceiling to hold. It is the wrong instrument for absorbing
424
+ pre-existing debt.
425
+
426
+ Note that neither choice is what protects new code. `floors` is an absolute
427
+ tree-wide comparison against the rollup; the forward pressure lives in
428
+ `newMethodCeiling` (a *new* method scoring above it fails, default 30) and in
429
+ `compareCrap`'s ratchet (an *existing* method fails when it regresses against
430
+ its own baseline row). Both are unaffected by how much old debt the gate can
431
+ now see, and neither consults `floors`.
432
+
433
+ **Old baselines are invalidated explicitly.** Rows scored by the previous join
434
+ are not comparable to rows scored by this one, and neither `kernelVersion` nor
435
+ `escomplexVersion` moves (both track the same upstream package). The envelope
436
+ therefore carries a `scoringSemantics` stamp; `check-baselines` fails closed on
437
+ a mismatch with the exact re-baseline command rather than comparing across the
438
+ boundary. Bump the stamp whenever the coverage join, the line coordinate
439
+ system, or the unresolved-method policy changes.
440
+
441
+ ---
442
+
443
+ ## Keeping a baseline fresh (Story #4776)
444
+
445
+ Populating a baseline correctly is only half the loop. The other half is
446
+ keeping it correct as the tree grows, and that half has two distinct holes —
447
+ one at close time, one over the long run. Both are **advisory**:
448
+ `check-baselines` already fails closed on a real regression, and duplicating
449
+ that would double-gate the same defect.
450
+
451
+ ### Pre-merge projections — the refresh nudge at close time
452
+
453
+ Close-validation projects, after its gates pass, which committed baseline rows
454
+ the post-merge tree would breach, and names the exact remedy while the operator
455
+ still has the branch in hand:
456
+
457
+ - `lib/close-validation/projections/maintainability.js` — per-file MI.
458
+ - `lib/close-validation/projections/crap.js` — per-method CRAP, against each
459
+ method's baseline row or, for methods with no row, `newMethodCeiling`.
460
+
461
+ Both are wired through `projections/advisories.js`, which
462
+ `close-validation/runner.js` calls once. Each self-skips — logging the reason,
463
+ never erroring — when its gate is disabled, when no baseline exists, when the
464
+ diff has no scorable files, or when the CRAP scorer finds no coverage
465
+ artifact. A projected breach never changes the close verdict.
466
+
467
+ > The maintainability projection shipped in v1 fully written and fully
468
+ > unit-tested, and the v2 Epic-tier collapse removed its only caller. It sat
469
+ > importable-but-unimported for the whole of v2, so its advisory never fired
470
+ > once. `tests/lib/close-validation/runner-projections.test.js` now walks the
471
+ > import graph and fails if **any** module under `projections/` is reachable
472
+ > from nothing in production — the orphaning itself is the regression.
473
+
474
+ ### `check-baseline-drift.js` — the scheduled full-scope re-score
475
+
476
+ Every per-PR enforcement site (close-validation, pre-push, CI) is
477
+ **diff-scoped**: it compares the files a branch touched against their baseline
478
+ rows. A file nobody touches after its row is written is therefore never
479
+ re-scored, so drift introduced *indirectly* — a dependency getting more
480
+ complex, coverage moving underneath a method — stays invisible indefinitely.
481
+ Full-scope scoring on every push is far too expensive to be the answer.
482
+
483
+ ```bash
484
+ node .agents/scripts/check-baseline-drift.js # both kinds
485
+ node .agents/scripts/check-baseline-drift.js --gate crap # one kind
486
+ node .agents/scripts/check-baseline-drift.js --tolerance 1 --json
487
+ ```
488
+
489
+ It re-scores full-scope through the *same* scorer that writes the baseline
490
+ (`refresh-service.resolveDefaultScorer`) — scoring by a second implementation
491
+ would report the two implementations' disagreement as drift — and prints a
492
+ per-row before/after table for everything that moved beyond the gate's
493
+ tolerance, **in either direction**. A row that silently improved is equally
494
+ strong evidence the baseline no longer describes the tree.
495
+
496
+ Exit codes: `0` no drift (or every kind skipped), `1` drift detected, `2` the
497
+ check could not run. It is designed to be wired as a scheduled CI job;
498
+ scheduling it is deliberately consumer-side work, and nothing in this
499
+ repository runs it automatically.
500
+
364
501
  ---
365
502
 
366
503
  ## Bundle-size ratchet — one-shot refresh/acknowledge (Story #151)
@@ -441,6 +441,45 @@
441
441
  },
442
442
  "additionalProperties": false
443
443
  },
444
+ "tempRetention": {
445
+ "type": "object",
446
+ "description": "Story #4794. Auto-purge of spent temp artifacts once their Story lands. Classification is an allowlist: only the declared classes below are ever deleted, so operator scratch files under tempRoot are reported with their size and left alone. signals.ndjson is never purged by any path.",
447
+ "properties": {
448
+ "enabled": {
449
+ "type": "boolean",
450
+ "description": "Master switch. Default true — reclaiming a landed Story's gate transcripts and validation evidence is the behaviour, and this knob turns it off. When false every purge path is a reported no-op."
451
+ },
452
+ "staleDays": {
453
+ "type": "integer",
454
+ "minimum": 1,
455
+ "description": "Age floor (days, default 7) for the families no Story id can be recovered from — roster-level audit reports and abandoned plan-<slug>/ dirs. Story-keyed artifacts do not wait for it: they are purged as soon as their merge is confirmed."
456
+ },
457
+ "classes": {
458
+ "type": "object",
459
+ "description": "Per-class opt-out. Each defaults to true; set one false to keep that family while the rest are purged.",
460
+ "properties": {
461
+ "orchestrationLogs": {
462
+ "type": "boolean",
463
+ "description": "<tempRoot>/orchestration/*.log — close gate transcripts and terse-result detail dumps."
464
+ },
465
+ "validationEvidence": {
466
+ "type": "boolean",
467
+ "description": "Per-Story validation-evidence.json, lifecycle.ndjson, and manifest.md under the standalone and per-run story trees."
468
+ },
469
+ "auditResults": {
470
+ "type": "boolean",
471
+ "description": "<tempRoot>/audits/ — audit lens reports."
472
+ },
473
+ "planDirs": {
474
+ "type": "boolean",
475
+ "description": "<tempRoot>/plan-<slug>/ — abandoned plan authoring dirs. Age-floored only; the current run is always excluded."
476
+ }
477
+ },
478
+ "additionalProperties": false
479
+ }
480
+ },
481
+ "additionalProperties": false
482
+ },
444
483
  "docsFreshness": {
445
484
  "type": "object",
446
485
  "properties": {
@@ -774,6 +813,12 @@
774
813
  "requireCoverage": {
775
814
  "type": "boolean"
776
815
  },
816
+ "minMethodResolutionRate": {
817
+ "type": "number",
818
+ "minimum": 0,
819
+ "maximum": 1,
820
+ "description": "Fail-closed floor on the per-method coverage JOIN (Story #4775): the fraction of methods that must resolve a coverage entry, counted only over files that HAVE one, before `update-crap-baseline.js` will persist. A broken join is silent by construction — unresolved methods are simply absent — so the updater refuses rather than writing a thin baseline and logging it as success. Not enforced below 25 joinable methods, where a diff-scoped run's rate is noise. Default 0.75; a healthy repo resolves ~98%."
821
+ },
777
822
  "friction": {
778
823
  "type": "object",
779
824
  "properties": {
@@ -1318,6 +1363,9 @@
1318
1363
  "docsFreshness": {
1319
1364
  "$ref": "#/$defs/docsFreshness"
1320
1365
  },
1366
+ "tempRetention": {
1367
+ "$ref": "#/$defs/tempRetention"
1368
+ },
1321
1369
  "deliverRunner": {
1322
1370
  "$ref": "#/$defs/deliverRunner"
1323
1371
  },
@@ -21,6 +21,10 @@
21
21
  "format": "date-time",
22
22
  "description": "ISO-8601 timestamp of the baseline run. Used for stale-baseline detection and audit trails."
23
23
  },
24
+ "scoringSemantics": {
25
+ "type": "string",
26
+ "description": "Optional per-kind stamp identifying the SCORING SEMANTICS that produced these rows — the dimension kernelVersion cannot express, because a kind's own scoring can change while the upstream package it stamps does not. A baseline whose stamp differs from the running scorer's is incomparable and the gate fails closed with re-baseline guidance rather than comparing across the boundary."
27
+ },
24
28
  "rollup": {
25
29
  "type": "object",
26
30
  "description": "Aggregate metrics keyed by component. The '*' key is reserved for the whole-repo rollup and is REQUIRED; additional keys MAY be present, one per declared component.",
@@ -6,6 +6,10 @@
6
6
  "allOf": [{ "$ref": "baseline-envelope.schema.json" }],
7
7
  "type": "object",
8
8
  "properties": {
9
+ "scoringSemantics": {
10
+ "type": "string",
11
+ "description": "Identifier for the per-method coverage-join semantics that produced these rows (Story #4775). Deliberately NOT constrained to the current value here: an old-semantics baseline must reach the gate's compat axis, which fails it closed with an explicit re-baseline command, rather than being rejected with a raw schema error."
12
+ },
9
13
  "rollup": {
10
14
  "type": "object",
11
15
  "required": ["*"],
@@ -86,13 +86,18 @@
86
86
  "followUps",
87
87
  "statusResync",
88
88
  "refCleanup",
89
- "baseFastForward"
89
+ "baseFastForward",
90
+ "tempPurge"
90
91
  ],
91
92
  "properties": {
92
93
  "followUps": { "type": "boolean" },
93
94
  "statusResync": { "type": "boolean" },
94
95
  "refCleanup": { "type": "boolean" },
95
96
  "baseFastForward": { "type": "boolean" },
97
+ "tempPurge": {
98
+ "type": "boolean",
99
+ "description": "Story #4794 — the merged Story's spent temp artifacts (gate transcripts, validation evidence) were purged under delivery.tempRetention. A disabled policy reports true: the operator turned the purge off, so doing nothing IS the correct outcome. Only a real failure — an unreadable temp root, an undeletable artifact — reports false, and like every tail step that degrades the report, never the land."
100
+ },
96
101
  "details": {
97
102
  "type": "object",
98
103
  "description": "Per-step diagnostic detail — the reason a false step reported false.",
@@ -82,10 +82,14 @@ const VERDICT_SCHEMA_PATH = path.resolve(
82
82
  'acceptance-eval-verdict.schema.json',
83
83
  );
84
84
 
85
- let cachedValidator = null;
86
-
87
85
  /**
88
- * Compile (and cache) the Ajv2020 validator for the verdict schema.
86
+ * Compile the Ajv2020 validator for the verdict schema.
87
+ *
88
+ * Deliberately **not** memoised in a module-level variable: a module-level
89
+ * cache is reset only on re-import, so it silently defeats the `io` seam below
90
+ * (the second caller's injected reader is never consulted) and makes parallel
91
+ * test isolation impossible — `.agents/rules/test-seams.md` rule 3. The gate
92
+ * compiles the schema once per CLI run, so there is nothing to memoise.
89
93
  *
90
94
  * @param {string} [schemaPath]
91
95
  * @param {{ readFileSync: typeof readFileSync }} [io]
@@ -95,12 +99,10 @@ function getVerdictValidator(
95
99
  schemaPath = VERDICT_SCHEMA_PATH,
96
100
  io = { readFileSync },
97
101
  ) {
98
- if (cachedValidator) return cachedValidator;
99
102
  const ajv = new Ajv2020({ allErrors: true, strict: false });
100
103
  addFormats(ajv);
101
104
  const schema = JSON.parse(io.readFileSync(schemaPath, 'utf8'));
102
- cachedValidator = ajv.compile(schema);
103
- return cachedValidator;
105
+ return ajv.compile(schema);
104
106
  }
105
107
 
106
108
  /**
@@ -235,7 +237,37 @@ export async function runAcceptanceEval(
235
237
  return { envelope, exitCode };
236
238
  }
237
239
 
238
- export async function main(argv = process.argv.slice(2)) {
240
+ /**
241
+ * The gate's CLI core: argv → verdict read → schema validation → decision →
242
+ * envelope. Extracted from the `main` shell so the whole error table (missing
243
+ * flags, unreadable verdict, non-JSON verdict, storyId mismatch, block) is
244
+ * reachable without spawning the CLI or writing a real verdict file.
245
+ *
246
+ * Every seam on the optional final `deps` parameter defaults to the real
247
+ * implementation (`.agents/rules/test-seams.md` rules 1-2, 4), so `main` and
248
+ * every production invocation are unchanged.
249
+ *
250
+ * @param {string[]} [argv]
251
+ * @param {{
252
+ * readFileSyncImpl?: typeof readFileSync,
253
+ * resolveConfigImpl?: typeof resolveConfig,
254
+ * validateVerdictImpl?: typeof validateVerdict,
255
+ * runAcceptanceEvalImpl?: typeof runAcceptanceEval,
256
+ * logger?: { info: Function },
257
+ * }} [deps]
258
+ * @returns {Promise<object>} the emitted envelope.
259
+ */
260
+ export async function runAcceptanceEvalCli(
261
+ argv = process.argv.slice(2),
262
+ deps = {},
263
+ ) {
264
+ const {
265
+ readFileSyncImpl = readFileSync,
266
+ resolveConfigImpl = resolveConfig,
267
+ validateVerdictImpl = validateVerdict,
268
+ runAcceptanceEvalImpl = runAcceptanceEval,
269
+ logger = Logger,
270
+ } = deps;
239
271
  const { storyId, verdictPath, emitSignal } = parseCliArgs(argv);
240
272
 
241
273
  if (!storyId) {
@@ -249,7 +281,7 @@ export async function main(argv = process.argv.slice(2)) {
249
281
 
250
282
  let raw;
251
283
  try {
252
- raw = readFileSync(path.resolve(verdictPath), 'utf8');
284
+ raw = readFileSyncImpl(path.resolve(verdictPath), 'utf8');
253
285
  } catch (err) {
254
286
  throw new Error(
255
287
  `acceptance-eval: cannot read verdict file at ${verdictPath}: ${
@@ -269,7 +301,7 @@ export async function main(argv = process.argv.slice(2)) {
269
301
  );
270
302
  }
271
303
 
272
- const verdict = validateVerdict(parsed);
304
+ const verdict = validateVerdictImpl(parsed);
273
305
 
274
306
  // A verdict whose embedded storyId disagrees with the CLI flag is a
275
307
  // wiring error worth failing on, not a silent mismatch.
@@ -279,15 +311,15 @@ export async function main(argv = process.argv.slice(2)) {
279
311
  );
280
312
  }
281
313
 
282
- const config = resolveConfig();
283
- const { envelope, exitCode } = await runAcceptanceEval({
314
+ const config = resolveConfigImpl();
315
+ const { envelope, exitCode } = await runAcceptanceEvalImpl({
284
316
  storyId,
285
317
  verdict,
286
318
  config,
287
319
  emitSignal,
288
320
  });
289
321
 
290
- Logger.info(JSON.stringify(envelope));
322
+ logger.info(JSON.stringify(envelope));
291
323
 
292
324
  if (exitCode !== 0) {
293
325
  const names = envelope.unmetCriteria
@@ -302,6 +334,14 @@ export async function main(argv = process.argv.slice(2)) {
302
334
  return envelope;
303
335
  }
304
336
 
337
+ /**
338
+ * @param {string[]} [argv]
339
+ * @returns {Promise<object>}
340
+ */
341
+ export async function main(argv = process.argv.slice(2)) {
342
+ return runAcceptanceEvalCli(argv);
343
+ }
344
+
305
345
  runAsCli(import.meta.url, main, {
306
346
  source: 'acceptance-eval',
307
347
  usage: {
@@ -224,8 +224,38 @@ function dedupDegradedWarning(entries) {
224
224
  );
225
225
  }
226
226
 
227
- async function buildPlan({ glob: pattern, severity, useProvider, ledger }) {
228
- const reportPaths = await collectReportPaths(pattern ?? DEFAULT_GLOB);
227
+ /**
228
+ * Scan group → dedup → (optionally) reconcile the cross-run ledger, and
229
+ * return the plan envelope.
230
+ *
231
+ * Every seam on the optional final `deps` parameter defaults to the real
232
+ * implementation (`.agents/rules/test-seams.md` rules 1-2, 4), so `main`,
233
+ * `runAuto`, and every production caller are unchanged.
234
+ *
235
+ * @param {{ glob?: string, severity?: string, useProvider?: boolean, ledger?: object }} params
236
+ * @param {{
237
+ * collectReportPathsImpl?: typeof collectReportPaths,
238
+ * readReportsImpl?: typeof readReports,
239
+ * loadProviderImpl?: typeof loadProvider,
240
+ * classifyGroupsImpl?: typeof classifyGroupsAgainstGitHub,
241
+ * reconcileScanLedgerImpl?: typeof reconcileScanLedger,
242
+ * logger?: { warn: Function },
243
+ * }} [deps]
244
+ * @returns {Promise<object>} the plan envelope.
245
+ */
246
+ async function buildPlan(
247
+ { glob: pattern, severity, useProvider, ledger },
248
+ deps = {},
249
+ ) {
250
+ const {
251
+ collectReportPathsImpl = collectReportPaths,
252
+ readReportsImpl = readReports,
253
+ loadProviderImpl = loadProvider,
254
+ classifyGroupsImpl = classifyGroupsAgainstGitHub,
255
+ reconcileScanLedgerImpl = reconcileScanLedger,
256
+ logger = Logger,
257
+ } = deps;
258
+ const reportPaths = await collectReportPathsImpl(pattern ?? DEFAULT_GLOB);
229
259
  if (reportPaths.length === 0) {
230
260
  return {
231
261
  generatedAt: new Date().toISOString(),
@@ -245,8 +275,8 @@ async function buildPlan({ glob: pattern, severity, useProvider, ledger }) {
245
275
  };
246
276
  }
247
277
 
248
- const reports = readReports(reportPaths);
249
- const allFindings = parseAuditReports(reports);
278
+ const reports = readReportsImpl(reportPaths);
279
+ const allFindings = parseAuditReports(reports, { repoRoot: process.cwd() });
250
280
  const filtered = allFindings.filter((f) => meetsSeverity(f, severity));
251
281
  const stamped = withFingerprints(filtered);
252
282
  const { groups, edges } = groupFindings(stamped);
@@ -261,9 +291,9 @@ async function buildPlan({ glob: pattern, severity, useProvider, ledger }) {
261
291
  let dedupApplied = false;
262
292
 
263
293
  if (useProvider) {
264
- const provider = await loadProvider();
294
+ const provider = await loadProviderImpl();
265
295
  if (provider) {
266
- const result = await classifyGroupsAgainstGitHub({
296
+ const result = await classifyGroupsImpl({
267
297
  groups,
268
298
  provider,
269
299
  searchCandidates: provider.searchCandidates,
@@ -275,19 +305,19 @@ async function buildPlan({ glob: pattern, severity, useProvider, ledger }) {
275
305
  // the --scan JSON on stdout stays clean) naming the groups that degraded
276
306
  // to create because their lookup could not complete (Story #4678).
277
307
  if (summary.dedupDegraded?.count > 0) {
278
- Logger.warn(dedupDegradedWarning(summary.dedupDegraded.groups));
308
+ logger.warn(dedupDegradedWarning(summary.dedupDegraded.groups));
279
309
  }
280
310
  } else {
281
311
  // The provider could not resolve a searchIssues port — the dedup gate
282
312
  // is silently a no-op without this. Surface it loudly (stderr, so the
283
313
  // --scan JSON on stdout stays clean) so the operator does not read a
284
314
  // create-only plan as "no duplicates found".
285
- Logger.warn(dedupSkippedWarning('no-provider-port'));
315
+ logger.warn(dedupSkippedWarning('no-provider-port'));
286
316
  }
287
317
  } else {
288
318
  // Operator explicitly opted out via --no-provider. Still warn so a
289
319
  // duplicate-opening re-run is never a surprise.
290
- Logger.warn(dedupSkippedWarning('disabled'));
320
+ logger.warn(dedupSkippedWarning('disabled'));
291
321
  }
292
322
 
293
323
  // Cross-run ledger (Story #4626): fold this scan onto the committed memory,
@@ -296,7 +326,7 @@ async function buildPlan({ glob: pattern, severity, useProvider, ledger }) {
296
326
  // --scan path leaves it untouched so it never mutates a committed file.
297
327
  let ledgerSummary;
298
328
  if (ledger) {
299
- const suppressed = reconcileScanLedger({
329
+ const suppressed = reconcileScanLedgerImpl({
300
330
  ledgerPath: ledger.path ?? DEFAULT_LEDGER_PATH,
301
331
  findings: stamped,
302
332
  classifications,
@@ -546,9 +576,42 @@ export const __testing = {
546
576
  issueStatesFromClassifications,
547
577
  };
548
578
 
549
- async function main() {
579
+ /**
580
+ * The CLI core: dispatch one of the four sub-commands and persist its output.
581
+ * Extracted from the `main` shell so the whole sub-command table (including
582
+ * the no-sub-command usage throw) is reachable without spawning the CLI.
583
+ *
584
+ * Every seam on the optional final `deps` parameter defaults to the real
585
+ * implementation (`.agents/rules/test-seams.md` rules 1-2, 4), so `main` and
586
+ * every production invocation are unchanged.
587
+ *
588
+ * @param {string[]} [argv]
589
+ * @param {{
590
+ * buildPlanImpl?: typeof buildPlan,
591
+ * runAutoImpl?: typeof runAuto,
592
+ * loadPlanImpl?: typeof loadPlan,
593
+ * buildAndGateStoriesImpl?: typeof buildAndGateStories,
594
+ * buildPlanSeedMarkdownImpl?: typeof buildPlanSeedMarkdown,
595
+ * persistImpl?: typeof persist,
596
+ * stdout?: { write: (s: string) => void },
597
+ * }} [deps]
598
+ * @returns {Promise<void>}
599
+ */
600
+ export async function runAuditToStories(
601
+ argv = process.argv.slice(2),
602
+ deps = {},
603
+ ) {
604
+ const {
605
+ buildPlanImpl = buildPlan,
606
+ runAutoImpl = runAuto,
607
+ loadPlanImpl = loadPlan,
608
+ buildAndGateStoriesImpl = buildAndGateStories,
609
+ buildPlanSeedMarkdownImpl = buildPlanSeedMarkdown,
610
+ persistImpl = persist,
611
+ stdout = process.stdout,
612
+ } = deps;
550
613
  const { values } = parseArgs({
551
- args: process.argv.slice(2),
614
+ args: argv,
552
615
  options: {
553
616
  scan: { type: 'boolean' },
554
617
  auto: { type: 'boolean' },
@@ -567,47 +630,47 @@ async function main() {
567
630
  });
568
631
 
569
632
  if (values.auto) {
570
- const { summary } = await runAuto({
633
+ const { summary } = await runAutoImpl({
571
634
  glob: values.glob,
572
635
  severity: values.severity,
573
636
  dryRun: values['dry-run'],
574
637
  useProvider: !values['no-provider'],
575
638
  ledgerPath: values.ledger,
576
639
  });
577
- persist(JSON.stringify(summary, null, 2), values.out);
578
- if (!values.out) process.stdout.write('\n');
640
+ persistImpl(JSON.stringify(summary, null, 2), values.out);
641
+ if (!values.out) stdout.write('\n');
579
642
  return;
580
643
  }
581
644
 
582
645
  if (values.scan) {
583
- const plan = await buildPlan({
646
+ const plan = await buildPlanImpl({
584
647
  glob: values.glob,
585
648
  severity: values.severity,
586
649
  useProvider: !values['no-provider'],
587
650
  });
588
651
  const out = JSON.stringify(plan, null, 2);
589
- persist(out, values.out);
590
- if (!values.out) process.stdout.write('\n');
652
+ persistImpl(out, values.out);
653
+ if (!values.out) stdout.write('\n');
591
654
  return;
592
655
  }
593
656
 
594
657
  if (values['emit-plan-seed']) {
595
- const plan = loadPlan(values.plan);
596
- const md = buildPlanSeedMarkdown({
658
+ const plan = loadPlanImpl(values.plan);
659
+ const md = buildPlanSeedMarkdownImpl({
597
660
  groups: plan.groups ?? [],
598
661
  findings: plan.findings ?? [],
599
662
  sourceReports: plan.sourceReports ?? [],
600
663
  });
601
- persist(md, values.out);
664
+ persistImpl(md, values.out);
602
665
  return;
603
666
  }
604
667
 
605
668
  if (values['emit-stories']) {
606
- const plan = loadPlan(values.plan);
669
+ const plan = loadPlanImpl(values.plan);
607
670
  const eligible = (plan.classifications ?? [])
608
671
  .filter((c) => c.action === 'create')
609
672
  .map((c) => c.group);
610
- const built = buildAndGateStories(eligible, plan.edges ?? []);
673
+ const built = buildAndGateStoriesImpl(eligible, plan.edges ?? []);
611
674
  const out = values.json
612
675
  ? JSON.stringify(built, null, 2)
613
676
  : built
@@ -616,8 +679,8 @@ async function main() {
616
679
  `--- story ${i + 1} ---\nTitle: ${s.title}\nLabels: ${s.labels.join(', ')}\n\n${s.body}\n`,
617
680
  )
618
681
  .join('\n');
619
- persist(out, values.out);
620
- if (!values.out) process.stdout.write('\n');
682
+ persistImpl(out, values.out);
683
+ if (!values.out) stdout.write('\n');
621
684
  return;
622
685
  }
623
686
 
@@ -626,6 +689,10 @@ async function main() {
626
689
  );
627
690
  }
628
691
 
692
+ async function main() {
693
+ await runAuditToStories();
694
+ }
695
+
629
696
  runAsCli(import.meta.url, main, {
630
697
  source: 'audit-to-stories',
631
698
  usage: {