mandrel 2.16.0 → 2.17.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 (57) hide show
  1. package/.agents/docs/configuration.md +1 -0
  2. package/.agents/docs/quality-gates.md +137 -0
  3. package/.agents/schemas/agentrc.schema.json +6 -0
  4. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  5. package/.agents/schemas/baselines/crap.schema.json +4 -0
  6. package/.agents/scripts/acceptance-eval.js +52 -12
  7. package/.agents/scripts/audit-to-stories.js +92 -25
  8. package/.agents/scripts/boot-sweep.js +28 -6
  9. package/.agents/scripts/check-baseline-drift.js +138 -0
  10. package/.agents/scripts/coverage-capture.js +74 -25
  11. package/.agents/scripts/deliver-recover.js +45 -18
  12. package/.agents/scripts/drain-pending-cleanup.js +67 -23
  13. package/.agents/scripts/generate-lens-checklists.js +81 -30
  14. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
  15. package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
  16. package/.agents/scripts/lib/baselines/envelope.js +7 -0
  17. package/.agents/scripts/lib/baselines/kernel.js +31 -0
  18. package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
  19. package/.agents/scripts/lib/baselines/reader.js +12 -1
  20. package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
  21. package/.agents/scripts/lib/baselines/writer.js +10 -0
  22. package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
  23. package/.agents/scripts/lib/cli-utils.js +48 -13
  24. package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
  25. package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
  26. package/.agents/scripts/lib/close-validation/runner.js +68 -0
  27. package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
  28. package/.agents/scripts/lib/config/quality.js +40 -0
  29. package/.agents/scripts/lib/coverage-utils.js +92 -9
  30. package/.agents/scripts/lib/crap-engine.js +113 -23
  31. package/.agents/scripts/lib/crap-utils.js +159 -93
  32. package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
  33. package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
  34. package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
  35. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
  36. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
  37. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
  38. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
  39. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +14 -0
  40. package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
  41. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
  42. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
  43. package/.agents/scripts/lib/stdio-flush.js +71 -0
  44. package/.agents/scripts/lib/transpile.js +133 -6
  45. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
  46. package/.agents/scripts/lib/workers/crap-worker.js +49 -76
  47. package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
  48. package/.agents/scripts/nav-registry-diff.js +30 -8
  49. package/.agents/scripts/plan-run-epilogue.js +27 -11
  50. package/.agents/scripts/resolve-doc-tiers.js +18 -8
  51. package/.agents/scripts/single-story-close.js +9 -92
  52. package/.agents/scripts/update-crap-baseline.js +13 -0
  53. package/README.md +14 -6
  54. package/docs/CHANGELOG.md +24 -0
  55. package/lib/cli/version-helpers.js +7 -0
  56. package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
  57. package/package.json +5 -1
@@ -32,6 +32,9 @@
32
32
  * skippedFileNoCoverage: boolean,
33
33
  * crapRows: Array<{ method, startLine, cyclomatic, coverage, crap }> | null,
34
34
  * skippedMethodsNoCoverage: number,
35
+ * hasCoverageEntry: boolean,
36
+ * resolvedMethods: number,
37
+ * totalMethods: number,
35
38
  * } }
36
39
  *
37
40
  * A read/transpile/parse failure surfaces as `crapRows: null` so the host
@@ -40,10 +43,10 @@
40
43
  * `miScore` is `null`; on a transpile/parse failure `miScore` is `0`.
41
44
  */
42
45
 
43
- import fs from 'node:fs';
44
46
  import { parentPort } from 'node:worker_threads';
47
+ import { finalizeMethodRows } from '../crap-engine.js';
45
48
  import { analyzeOnce } from '../crap-utils.js';
46
- import { transpileIfNeeded } from '../transpile.js';
49
+ import { prepareSourceForScoring } from '../transpile.js';
47
50
 
48
51
  /**
49
52
  * Pure handler for a single inbound worker message. Exported so unit tests
@@ -58,8 +61,9 @@ import { transpileIfNeeded } from '../transpile.js';
58
61
  * @param {unknown} msg
59
62
  * @param {{
60
63
  * readFile?: (abs: string) => string,
61
- * transpile?: (abs: string, source: string) => string | null,
62
- * analyze?: (source: string, entry: object|null) => {
64
+ * transpile?: (abs: string, source: string, opts?: object) => unknown,
65
+ * prepare?: (abs: string, deps: object) => object,
66
+ * analyze?: (source: string, entry: object|null, mapLine: Function|null) => {
63
67
  * miScore: number,
64
68
  * crapRows: Array<object>,
65
69
  * parseError: boolean,
@@ -85,8 +89,7 @@ export function handleCombinedMiCrapWorkerMessage(msg, deps = {}) {
85
89
  };
86
90
  }
87
91
  const { abs, relPath, requireCoverage } = item;
88
- const readFile = deps.readFile ?? ((p) => fs.readFileSync(p, 'utf-8'));
89
- const transpile = deps.transpile ?? transpileIfNeeded;
92
+ const prepare = deps.prepare ?? prepareSourceForScoring;
90
93
  const analyze = deps.analyze ?? analyzeOnce;
91
94
 
92
95
  // Coverage entry is pre-resolved on the host and attached to the item.
@@ -94,47 +97,33 @@ export function handleCombinedMiCrapWorkerMessage(msg, deps = {}) {
94
97
  // coverage, or `undefined` when the caller did not supply it (treat as null).
95
98
  const entry = item.coverageEntry ?? null;
96
99
 
97
- // Read the source once. A read failure means neither MI nor CRAP can be
98
- // computed — MI drops (null), CRAP drops (rows null) — matching the two
99
- // passes' read-failure contracts (calculateAll → score null; crap worker
100
- // → rows null).
101
- let source;
102
- try {
103
- source = readFile(abs);
104
- } catch {
105
- return {
106
- kind: 'reply',
107
- message: {
108
- ok: true,
109
- result: {
110
- relPath,
111
- miScore: null,
112
- skippedFileNoCoverage: false,
113
- crapRows: null,
114
- skippedMethodsNoCoverage: 0,
115
- },
100
+ const reply = (result) => ({
101
+ kind: 'reply',
102
+ message: {
103
+ ok: true,
104
+ result: {
105
+ relPath,
106
+ skippedFileNoCoverage: false,
107
+ skippedMethodsNoCoverage: 0,
108
+ hasCoverageEntry: entry !== null,
109
+ resolvedMethods: 0,
110
+ totalMethods: 0,
111
+ ...result,
116
112
  },
117
- };
118
- }
113
+ },
114
+ });
119
115
 
120
- // TS/TSX strip-then-analyze. A transpile failure yields miScore 0
121
- // (calculateForFile returns 0 when transpileIfNeeded returns null) and a
122
- // null CRAP contribution (crap worker returns rows: null).
123
- const prepared = transpile(abs, source);
124
- if (prepared === null) {
125
- return {
126
- kind: 'reply',
127
- message: {
128
- ok: true,
129
- result: {
130
- relPath,
131
- miScore: 0,
132
- skippedFileNoCoverage: false,
133
- crapRows: null,
134
- skippedMethodsNoCoverage: 0,
135
- },
136
- },
137
- };
116
+ // Read + transpile once, carrying the transpiled -> original-source line
117
+ // map the per-method coverage join needs (Story #4775). A read failure
118
+ // means neither MI nor CRAP can be computed (MI drops as null, matching
119
+ // `calculateAll`'s filter); a transpile failure scores MI 0 (matching
120
+ // `calculateForFile`). CRAP drops the file either way.
121
+ const prepared = prepare(abs, deps);
122
+ if (prepared.error) {
123
+ return reply({
124
+ miScore: prepared.error === 'read' ? null : 0,
125
+ crapRows: null,
126
+ });
138
127
  }
139
128
 
140
129
  // ONE parse: analyzeOnce derives both the module MI score and the raw
@@ -144,24 +133,12 @@ export function handleCombinedMiCrapWorkerMessage(msg, deps = {}) {
144
133
  miScore,
145
134
  crapRows: rawCrapRows,
146
135
  parseError,
147
- } = analyze(prepared, entry);
136
+ } = analyze(prepared.code, entry, prepared.mapLine);
148
137
  if (parseError) {
149
- // Parse error: MI scores 0 (parity with calculateForSource's catch
138
+ // Parse error: MI scores 0 (parity with calculateForSource's catch ->
150
139
  // returns 0), CRAP drops the file (rows null, parity with the crap
151
140
  // worker's calculateCrap-throw branch).
152
- return {
153
- kind: 'reply',
154
- message: {
155
- ok: true,
156
- result: {
157
- relPath,
158
- miScore: 0,
159
- skippedFileNoCoverage: false,
160
- crapRows: null,
161
- skippedMethodsNoCoverage: 0,
162
- },
163
- },
164
- };
141
+ return reply({ miScore: 0, crapRows: null });
165
142
  }
166
143
 
167
144
  // CRAP coverage gate runs AFTER the parse so the MI score is always
@@ -169,49 +146,18 @@ export function handleCombinedMiCrapWorkerMessage(msg, deps = {}) {
169
146
  // pass would have skipped it at the file level (no rows, counted) — but the
170
147
  // MI pass would still have scored it, so miScore is returned regardless.
171
148
  if (requireCoverage && entry === null) {
172
- return {
173
- kind: 'reply',
174
- message: {
175
- ok: true,
176
- result: {
177
- relPath,
178
- miScore,
179
- skippedFileNoCoverage: true,
180
- crapRows: [],
181
- skippedMethodsNoCoverage: 0,
182
- },
183
- },
184
- };
149
+ return reply({ miScore, skippedFileNoCoverage: true, crapRows: [] });
185
150
  }
186
151
 
187
- const crapRows = [];
188
- let skippedMethodsNoCoverage = 0;
189
- for (const mr of rawCrapRows) {
190
- if (mr.crap === null || mr.coverage === null) {
191
- skippedMethodsNoCoverage += 1;
192
- continue;
193
- }
194
- crapRows.push({
195
- method: mr.method,
196
- startLine: mr.startLine,
197
- cyclomatic: mr.cyclomatic,
198
- coverage: mr.coverage,
199
- crap: mr.crap,
200
- });
201
- }
202
- return {
203
- kind: 'reply',
204
- message: {
205
- ok: true,
206
- result: {
207
- relPath,
208
- miScore,
209
- skippedFileNoCoverage: false,
210
- crapRows,
211
- skippedMethodsNoCoverage,
212
- },
213
- },
214
- };
152
+ const { rows, skippedMethodsNoCoverage, resolvedMethods, totalMethods } =
153
+ finalizeMethodRows(rawCrapRows, { requireCoverage });
154
+ return reply({
155
+ miScore,
156
+ crapRows: rows,
157
+ skippedMethodsNoCoverage,
158
+ resolvedMethods,
159
+ totalMethods,
160
+ });
215
161
  }
216
162
 
217
163
  if (parentPort) {
@@ -18,6 +18,9 @@
18
18
  * skippedFileNoCoverage: boolean,
19
19
  * rows: Array<{ method, startLine, cyclomatic, coverage, crap }>,
20
20
  * skippedMethodsNoCoverage: number,
21
+ * hasCoverageEntry: boolean,
22
+ * resolvedMethods: number,
23
+ * totalMethods: number,
21
24
  * } }
22
25
  *
23
26
  * A truly unrecoverable per-file failure (read error, transpile null)
@@ -26,10 +29,9 @@
26
29
  * aborts the whole scan.
27
30
  */
28
31
 
29
- import fs from 'node:fs';
30
32
  import { parentPort } from 'node:worker_threads';
31
- import { calculateCrapForSource } from '../crap-engine.js';
32
- import { transpileIfNeeded } from '../transpile.js';
33
+ import { calculateCrapForSource, finalizeMethodRows } from '../crap-engine.js';
34
+ import { prepareSourceForScoring } from '../transpile.js';
33
35
 
34
36
  /**
35
37
  * Pure handler for a single inbound worker message. Exported so unit
@@ -42,7 +44,10 @@ import { transpileIfNeeded } from '../transpile.js';
42
44
  * - `{ kind: 'reply', message }` — caller should `postMessage(message)`.
43
45
  *
44
46
  * Side effects (fs, transpile, escomplex) are wired through `deps` so
45
- * tests pass deterministic stubs.
47
+ * tests pass deterministic stubs. `readFile` / `transpile` are forwarded to
48
+ * `prepareSourceForScoring`, which also derives the transpiled →
49
+ * original-source line map the coverage join needs (Story #4775); a
50
+ * `transpile` stub that still returns a bare string is tolerated.
46
51
  *
47
52
  * Coverage is supplied via `item.coverageEntry` (pre-resolved on the host),
48
53
  * not via a whole-map `coverage` argument. The second parameter is kept as
@@ -52,8 +57,9 @@ import { transpileIfNeeded } from '../transpile.js';
52
57
  * @param {object|null} _coverage - Unused. Coverage is in `item.coverageEntry`.
53
58
  * @param {{
54
59
  * readFile?: (abs: string) => string,
55
- * transpile?: (abs: string, source: string) => string | null,
56
- * calculateCrap?: (source: string, entry: object|null) => Array<object>,
60
+ * transpile?: (abs: string, source: string, opts?: object) => unknown,
61
+ * prepare?: (abs: string, deps: object) => object,
62
+ * calculateCrap?: (source: string, entry: object|null, mapLine: Function|null) => Array<object>,
57
63
  * }} [deps]
58
64
  * @returns {{kind: 'exit'} | {kind: 'reply', message: object}}
59
65
  */
@@ -75,9 +81,6 @@ export function handleCrapWorkerMessage(msg, _coverage, deps = {}) {
75
81
  };
76
82
  }
77
83
  const { abs, relPath, requireCoverage } = item;
78
- const readFile = deps.readFile ?? ((p) => fs.readFileSync(p, 'utf-8'));
79
- const transpile = deps.transpile ?? transpileIfNeeded;
80
- const calculateCrap = deps.calculateCrap ?? calculateCrapForSource;
81
84
 
82
85
  // Coverage entry is pre-resolved on the host and attached to the item.
83
86
  // `item.coverageEntry` may be explicitly `null` when the file has no
@@ -93,84 +96,54 @@ export function handleCrapWorkerMessage(msg, _coverage, deps = {}) {
93
96
  skippedFileNoCoverage: true,
94
97
  rows: [],
95
98
  skippedMethodsNoCoverage: 0,
99
+ hasCoverageEntry: false,
100
+ resolvedMethods: 0,
101
+ totalMethods: 0,
96
102
  },
97
103
  },
98
104
  };
99
105
  }
100
106
 
101
- let source;
102
- try {
103
- source = readFile(abs);
104
- } catch {
105
- return {
106
- kind: 'reply',
107
- message: {
108
- ok: true,
109
- result: {
110
- relPath,
111
- skippedFileNoCoverage: false,
112
- rows: null,
113
- skippedMethodsNoCoverage: 0,
114
- },
107
+ const dropped = (error) => ({
108
+ kind: 'reply',
109
+ message: {
110
+ ok: true,
111
+ result: {
112
+ relPath,
113
+ skippedFileNoCoverage: false,
114
+ rows: null,
115
+ skippedMethodsNoCoverage: 0,
116
+ hasCoverageEntry: entry !== null,
117
+ resolvedMethods: 0,
118
+ totalMethods: 0,
119
+ ...(error ? { error } : {}),
115
120
  },
116
- };
117
- }
121
+ },
122
+ });
118
123
 
119
- // TS/TSX strip-then-analyze. Coverage lookup above used the original
120
- // source path (vitest's coverage-final.json keys on the .ts file, not
121
- // transpiled output); the transpile is purely about making the code
122
- // parseable by the Esprima-based escomplex kernel.
123
- const prepared = transpile(abs, source);
124
- if (prepared === null) {
125
- return {
126
- kind: 'reply',
127
- message: {
128
- ok: true,
129
- result: {
130
- relPath,
131
- skippedFileNoCoverage: false,
132
- rows: null,
133
- skippedMethodsNoCoverage: 0,
134
- },
135
- },
136
- };
137
- }
124
+ // TS/TSX -> transpile-then-analyze, carrying the source map. The coverage
125
+ // lookup above used the ORIGINAL source path (vitest's coverage-final.json
126
+ // keys on the .ts file, not transpiled output) and the per-method join
127
+ // below uses ORIGINAL source *lines*, remapped from escomplex's transpiled
128
+ // coordinates via `prepared.mapLine` (Story #4775).
129
+ const prepare = deps.prepare ?? prepareSourceForScoring;
130
+ const prepared = prepare(abs, deps);
131
+ if (prepared.error) return dropped(null);
138
132
 
139
133
  let methodRows;
140
134
  try {
141
- methodRows = calculateCrap(prepared, entry);
135
+ methodRows = (deps.calculateCrap ?? calculateCrapForSource)(
136
+ prepared.code,
137
+ entry,
138
+ prepared.mapLine,
139
+ );
142
140
  } catch (err) {
143
- return {
144
- kind: 'reply',
145
- message: {
146
- ok: true,
147
- result: {
148
- relPath,
149
- skippedFileNoCoverage: false,
150
- rows: null,
151
- skippedMethodsNoCoverage: 0,
152
- error:
153
- err && typeof err.message === 'string' ? err.message : String(err),
154
- },
155
- },
156
- };
141
+ return dropped(
142
+ err && typeof err.message === 'string' ? err.message : String(err),
143
+ );
157
144
  }
158
145
 
159
- const rows = [];
160
- let skippedMethodsNoCoverage = 0;
161
- for (const mr of methodRows) {
162
- if (mr.crap === null || mr.coverage === null) {
163
- skippedMethodsNoCoverage += 1;
164
- continue;
165
- }
166
- rows.push({
167
- method: mr.method,
168
- startLine: mr.startLine,
169
- cyclomatic: mr.cyclomatic,
170
- coverage: mr.coverage,
171
- crap: mr.crap,
172
- });
173
- }
146
+ const finalized = finalizeMethodRows(methodRows, { requireCoverage });
174
147
  return {
175
148
  kind: 'reply',
176
149
  message: {
@@ -178,8 +151,8 @@ export function handleCrapWorkerMessage(msg, _coverage, deps = {}) {
178
151
  result: {
179
152
  relPath,
180
153
  skippedFileNoCoverage: false,
181
- rows,
182
- skippedMethodsNoCoverage,
154
+ hasCoverageEntry: entry !== null,
155
+ ...finalized,
183
156
  },
184
157
  },
185
158
  };
@@ -15,6 +15,7 @@
15
15
 
16
16
  import fs from 'node:fs';
17
17
  import { rm as fsPromisesRm } from 'node:fs/promises';
18
+ import { fileURLToPath } from 'node:url';
18
19
  import { isInsideWorktree, samePath } from '../inspector.js';
19
20
  import { sleepSync } from '../node-modules-strategy.js';
20
21
  import { checkMergeReachability } from './merge-reachability.js';
@@ -493,20 +494,92 @@ async function deleteBranchAfterReap(ctx, { branch, push }) {
493
494
  return { branchDeleted, remoteBranchDeleted };
494
495
  }
495
496
 
497
+ /**
498
+ * The on-disk paths of the code currently executing: the entry script and
499
+ * this module itself.
500
+ *
501
+ * @returns {string[]}
502
+ */
503
+ function runningCodePaths() {
504
+ const paths = [];
505
+ const entry = process.argv[1];
506
+ if (typeof entry === 'string' && entry !== '') paths.push(entry);
507
+ try {
508
+ paths.push(fileURLToPath(import.meta.url));
509
+ } catch {
510
+ // A non-file module URL cannot be inside a worktree; nothing to add.
511
+ }
512
+ return paths;
513
+ }
514
+
515
+ /**
516
+ * Return the running-code path that lives inside `wtPath`, or `null`.
517
+ *
518
+ * `escapeWorktreeCwd` already handles the *cwd* being inside the doomed
519
+ * tree; this is the other half — the **code** being inside it. An agent that
520
+ * invokes `node .agents/scripts/single-story-close.js` with its shell cwd set
521
+ * to the Story worktree runs the WORKTREE's copy of the script, and reaping
522
+ * the tree then deletes the running program out from under itself. Node has
523
+ * already loaded the static module graph, so the process does not die on the
524
+ * spot — it dies later, at the first lazy read or dynamic `import()`, in
525
+ * whatever phase happens to need one. That is how a close whose PR merged,
526
+ * whose Story flipped to `agent::done`, and whose post-land tail was green
527
+ * still exited non-zero with no terminal envelope.
528
+ *
529
+ * Refusing the reap is cheap: the tree survives one extra cycle and the next
530
+ * boot sweep takes it. Reaping it costs the run's return contract.
531
+ *
532
+ * @param {object} ctx
533
+ * @param {string} wtPath
534
+ * @returns {string|null}
535
+ */
536
+ function findRunningCodeInside(ctx, wtPath) {
537
+ return (
538
+ runningCodePaths().find((p) => isInsideWorktree(p, wtPath, ctx.platform)) ??
539
+ null
540
+ );
541
+ }
542
+
543
+ /**
544
+ * The refusals that disqualify a tree before any git work happens, in the
545
+ * order they are checked. Returns the refusal envelope, or `null` when the
546
+ * tree is eligible for the safety checks that follow.
547
+ *
548
+ * @param {object} ctx
549
+ * @param {object} opts
550
+ * @param {string} wtPath
551
+ * @returns {object|null}
552
+ */
553
+ function firstReapRefusal(ctx, opts, wtPath) {
554
+ const known = opts.worktrees
555
+ ? opts.worktrees.some((r) => samePath(r.path, wtPath, ctx.platform))
556
+ : findByPath(ctx, wtPath) !== null;
557
+ if (!known) return { removed: false, reason: 'not-a-worktree', path: wtPath };
558
+
559
+ const selfPath = findRunningCodeInside(ctx, wtPath);
560
+ if (!selfPath) return null;
561
+ ctx.logger.warn(
562
+ `reap-skipped reason=running-from-target-tree path=${wtPath} selfPath=${selfPath} — ` +
563
+ `the running process was loaded from this worktree; removing it would break every ` +
564
+ `later lazy read and dynamic import in this run. Left for the next sweep. ` +
565
+ `Invoke the script by its MAIN-checkout path to reap in-run.`,
566
+ );
567
+ return {
568
+ removed: false,
569
+ reason: 'running-from-target-tree',
570
+ path: wtPath,
571
+ selfPath,
572
+ };
573
+ }
574
+
496
575
  function checkReapPreconditions(ctx, _storyId, opts, wtPath) {
497
576
  if (opts.force) {
498
577
  throw new Error(
499
578
  'WorktreeManager.reap: --force is not permitted by the framework',
500
579
  );
501
580
  }
502
- const known = opts.worktrees
503
- ? opts.worktrees.some((r) => samePath(r.path, wtPath, ctx.platform))
504
- : findByPath(ctx, wtPath) !== null;
505
- if (!known)
506
- return {
507
- ok: false,
508
- result: { removed: false, reason: 'not-a-worktree', path: wtPath },
509
- };
581
+ const refusal = firstReapRefusal(ctx, opts, wtPath);
582
+ if (refusal) return { ok: false, result: refusal };
510
583
  // Story #4539 removed an `epic-branch-required` gate here: a
511
584
  // `story-<id>` worktree used to be unreapable unless the caller supplied
512
585
  // an Epic integration branch. v2 has no Epic branch, and the only v2
@@ -340,12 +340,13 @@ export function computeNavDiff({ routes = [], nav = [], refs = [] } = {}) {
340
340
  *
341
341
  * @param {string} label human-readable role for the error message
342
342
  * @param {string} file
343
+ * @param {typeof fs} [fsImpl] filesystem seam; defaults to the real `node:fs`.
343
344
  * @returns {unknown[]}
344
345
  */
345
- function readJsonArray(label, file) {
346
+ function readJsonArray(label, file, fsImpl = fs) {
346
347
  let raw;
347
348
  try {
348
- raw = fs.readFileSync(file, 'utf8');
349
+ raw = fsImpl.readFileSync(file, 'utf8');
349
350
  } catch (err) {
350
351
  throw new Error(
351
352
  `nav-registry-diff: cannot read ${label} file '${file}': ${err.message}`,
@@ -400,10 +401,23 @@ export function formatDiffText(diff) {
400
401
  }
401
402
 
402
403
  /**
403
- * @param {string[]} argv
404
+ * The reporter core, extracted from the CLI shell so the argv → read → diff →
405
+ * render → exit-code path is reachable without touching the real filesystem or
406
+ * the real stdout.
407
+ *
408
+ * Both seams on the optional final `deps` parameter default to the real
409
+ * implementation (`.agents/rules/test-seams.md` rules 1-2, 4 — `readJsonArray`
410
+ * forwards `fsImpl` rather than re-acquiring `fs`), so `main` and every
411
+ * production invocation are unchanged.
412
+ *
413
+ * @param {string[]} [argv]
414
+ * @param {{ fsImpl?: typeof fs, stdout?: { write: (s: string) => void } }} [deps]
404
415
  * @returns {Promise<number>} process exit code
405
416
  */
406
- async function main(argv = process.argv.slice(2)) {
417
+ export async function runNavRegistryDiff(
418
+ argv = process.argv.slice(2),
419
+ { fsImpl = fs, stdout = process.stdout } = {},
420
+ ) {
407
421
  const { values } = parseArgs({
408
422
  args: argv,
409
423
  options: {
@@ -423,9 +437,9 @@ async function main(argv = process.argv.slice(2)) {
423
437
  );
424
438
  }
425
439
 
426
- const routes = readJsonArray('routes', values.routes);
427
- const nav = readJsonArray('nav', values.nav);
428
- const refs = values.refs ? readJsonArray('refs', values.refs) : [];
440
+ const routes = readJsonArray('routes', values.routes, fsImpl);
441
+ const nav = readJsonArray('nav', values.nav, fsImpl);
442
+ const refs = values.refs ? readJsonArray('refs', values.refs, fsImpl) : [];
429
443
 
430
444
  const diff = computeNavDiff({ routes, nav, refs });
431
445
 
@@ -434,13 +448,21 @@ async function main(argv = process.argv.slice(2)) {
434
448
  const rendered = values.json
435
449
  ? JSON.stringify(diff, null, 2)
436
450
  : formatDiffText(diff);
437
- process.stdout.write(`${rendered}\n`);
451
+ stdout.write(`${rendered}\n`);
438
452
 
439
453
  const hasFindings =
440
454
  diff.orphanedRoutes.length > 0 || diff.deadHrefs.length > 0;
441
455
  return values.strict && hasFindings ? 1 : 0;
442
456
  }
443
457
 
458
+ /**
459
+ * @param {string[]} [argv]
460
+ * @returns {Promise<number>} process exit code
461
+ */
462
+ async function main(argv = process.argv.slice(2)) {
463
+ return runNavRegistryDiff(argv);
464
+ }
465
+
444
466
  export { main };
445
467
 
446
468
  runAsCli(import.meta.url, main, {
@@ -27,9 +27,23 @@ const CLI_OPTIONS = {
27
27
 
28
28
  /**
29
29
  * @param {string[]} [argv]
30
+ * @param {{
31
+ * resolveConfigImpl?: typeof resolveConfig,
32
+ * createProviderImpl?: typeof createProvider,
33
+ * runPlanRunEpilogueImpl?: typeof runPlanRunEpilogue,
34
+ * logger?: { info: Function, warn: Function },
35
+ * }} [deps] Injectable seams; every entry defaults to the real
36
+ * implementation (`.agents/rules/test-seams.md` rules 1-2), so the CLI path
37
+ * and every production caller are unchanged.
30
38
  * @returns {Promise<object>}
31
39
  */
32
- export async function main(argv = process.argv.slice(2)) {
40
+ export async function main(argv = process.argv.slice(2), deps = {}) {
41
+ const {
42
+ resolveConfigImpl = resolveConfig,
43
+ createProviderImpl = createProvider,
44
+ runPlanRunEpilogueImpl = runPlanRunEpilogue,
45
+ logger = Logger,
46
+ } = deps;
33
47
  const { values } = parseArgs({
34
48
  args: argv,
35
49
  options: CLI_OPTIONS,
@@ -44,8 +58,8 @@ export async function main(argv = process.argv.slice(2)) {
44
58
  typeof values.cwd === 'string' && values.cwd.trim()
45
59
  ? values.cwd.trim()
46
60
  : process.cwd();
47
- const config = resolveConfig({ cwd });
48
- const provider = createProvider(config);
61
+ const config = resolveConfigImpl({ cwd });
62
+ const provider = createProviderImpl(config);
49
63
 
50
64
  // Story #4540 retired the `--run <planRunId>` label-resolution branch
51
65
  // along with the label itself. The epilogue is keyed on the delivered id
@@ -58,16 +72,16 @@ export async function main(argv = process.argv.slice(2)) {
58
72
 
59
73
  const planRunId = `adhoc-${[...stories].sort((a, b) => a - b).join('-')}`;
60
74
 
61
- const result = await runPlanRunEpilogue({
75
+ const result = await runPlanRunEpilogueImpl({
62
76
  planRunId,
63
77
  stories,
64
78
  provider,
65
79
  config,
66
80
  cwd,
67
81
  });
68
- warnOnUnresolvedBase(result);
69
- warnOnEmptyRollup(result);
70
- Logger.info(JSON.stringify(result));
82
+ warnOnUnresolvedBase(result, logger);
83
+ warnOnEmptyRollup(result, logger);
84
+ logger.info(JSON.stringify(result));
71
85
  if (result.errors?.length) {
72
86
  process.exitCode = 1;
73
87
  }
@@ -84,15 +98,16 @@ export async function main(argv = process.argv.slice(2)) {
84
98
  * roster is still useful.
85
99
  *
86
100
  * @param {object} result - `runPlanRunEpilogue` envelope.
101
+ * @param {{ warn: Function }} [logger]
87
102
  * @returns {void}
88
103
  */
89
- function warnOnUnresolvedBase(result) {
104
+ function warnOnUnresolvedBase(result, logger = Logger) {
90
105
  const roster = (result?.results ?? []).find(
91
106
  (r) => r?.kind === 'audit-roster',
92
107
  );
93
108
  const base = roster?.baseResolution;
94
109
  if (base?.resolved !== false) return;
95
- Logger.warn(
110
+ logger.warn(
96
111
  `⚠️ Combined landed diff unavailable — the pre-run base sha could not be ` +
97
112
  `resolved against \`${base.baseRef}\`: ${base.reason}\n` +
98
113
  ` changedFiles is null (NOT an empty set). Determine the run diff by ` +
@@ -117,14 +132,15 @@ function warnOnUnresolvedBase(result) {
117
132
  * rather than asserting either reading.
118
133
  *
119
134
  * @param {object} result - `runPlanRunEpilogue` envelope.
135
+ * @param {{ warn: Function }} [logger]
120
136
  * @returns {void}
121
137
  */
122
- function warnOnEmptyRollup(result) {
138
+ function warnOnEmptyRollup(result, logger = Logger) {
123
139
  const rollup = (result?.results ?? []).find(
124
140
  (r) => r?.kind === 'follow-up-rollup',
125
141
  );
126
142
  if (!rollup?.emptyRollupSuspect) return;
127
- Logger.warn(
143
+ logger.warn(
128
144
  `⚠️ 0 friction signals across ${rollup.storyCount} Stories — telemetry may not ` +
129
145
  `have fired.\n` +
130
146
  ` An empty roll-up is NOT evidence of a clean run: it is the same output a ` +