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
@@ -46,6 +46,25 @@ import { Logger } from './lib/Logger.js';
46
46
  import { createProvider } from './lib/provider-factory.js';
47
47
  import { buildProtectionCtx } from './lib/single-story-sweep/protection-ctx.js';
48
48
  import { sweepMergedBranches } from './lib/single-story-sweep.js';
49
+ import { sweepTempRetention } from './lib/temp-retention.js';
50
+
51
+ /**
52
+ * Recover the Story ids from the branch names a sweep reaped. Only the
53
+ * canonical `story-<id>` shape yields an id — an operator's ad-hoc branch that
54
+ * happened to match the include glob contributes nothing, so a purge can never
55
+ * be triggered by a name this framework did not create.
56
+ *
57
+ * @param {string[]|undefined} branches
58
+ * @returns {number[]}
59
+ */
60
+ export function storyIdsFromBranches(branches) {
61
+ const ids = [];
62
+ for (const branch of Array.isArray(branches) ? branches : []) {
63
+ const match = /^story-(\d+)$/.exec(String(branch));
64
+ if (match) ids.push(Number(match[1]));
65
+ }
66
+ return ids;
67
+ }
49
68
 
50
69
  const HELP = `Usage: node .agents/scripts/boot-sweep.js [options]
51
70
 
@@ -85,9 +104,11 @@ Options:
85
104
  * injectedConfig?: object,
86
105
  * injectedProvider?: object,
87
106
  * injectedSweep?: Function,
107
+ * purgeFn?: Function,
88
108
  * logger?: { info?: Function, warn?: Function },
89
109
  * }} [args]
90
- * @returns {Promise<object>} the {@link sweepMergedBranches} envelope.
110
+ * @returns {Promise<object>} the {@link sweepMergedBranches} envelope, plus a
111
+ * `tempPurge` result from the Story #4794 temp-retention catch-up.
91
112
  */
92
113
  export async function runBootSweep({
93
114
  cwd,
@@ -99,6 +120,7 @@ export async function runBootSweep({
99
120
  injectedConfig,
100
121
  injectedProvider,
101
122
  injectedSweep,
123
+ purgeFn = sweepTempRetention,
102
124
  logger = Logger,
103
125
  } = {}) {
104
126
  const root = path.resolve(cwd ?? PROJECT_ROOT);
@@ -125,7 +147,7 @@ export async function runBootSweep({
125
147
  config.delivery?.worktreeIsolation?.sweepLockMs ?? 60_000;
126
148
 
127
149
  const sweepFn = injectedSweep ?? sweepMergedBranches;
128
- return await sweepFn({
150
+ const result = await sweepFn({
129
151
  cwd: root,
130
152
  baseBranch,
131
153
  include: includeGlobs,
@@ -140,6 +162,21 @@ export async function runBootSweep({
140
162
  lockPath,
141
163
  lockTimeoutMs,
142
164
  });
165
+
166
+ // Story #4794 — the temp-retention catch-up. Two eligibility signals, both
167
+ // already paid for: every branch this sweep reaped is a merge it CONFIRMED
168
+ // (merged PR + matching headRefOid), so those Stories' artifacts are spent;
169
+ // and the age floor collects everything else — the backlog from Stories
170
+ // merged before this existed, merged through the GitHub UI, or whose branch
171
+ // was already gone. Best-effort like the sweep itself: `runBootSweep`'s
172
+ // catch swallows any throw into the `ok: false` envelope, and exit stays 0.
173
+ const purge = await purgeFn({
174
+ config,
175
+ mergedStoryIds: storyIdsFromBranches(result?.reaped),
176
+ label: 'boot-sweep',
177
+ logger,
178
+ });
179
+ return { ...result, tempPurge: purge };
143
180
  } catch (err) {
144
181
  const msg = err?.message ?? String(err);
145
182
  logger.warn?.(`[boot-sweep] sweep threw (host continues): ${msg}`);
@@ -176,8 +213,25 @@ export function buildSummaryLine(result) {
176
213
  return `[boot-sweep] reaped ${result.localDeleted} local + ${result.remoteDeleted} remote; protected ${protectedCount}${contentMergedSuffix}.`;
177
214
  }
178
215
 
179
- async function main() {
216
+ /**
217
+ * The CLI core: parse argv, run the sweep, render the report. Extracted from
218
+ * the `main` shell so the argv → render decision table is reachable without
219
+ * spawning a real sweep against a real git tree.
220
+ *
221
+ * Both seams on the optional final `deps` parameter default to the real
222
+ * implementation (`.agents/rules/test-seams.md` rules 1-2), so `main` and any
223
+ * production caller are unchanged.
224
+ *
225
+ * @param {string[]} [argv]
226
+ * @param {{ runBootSweepImpl?: typeof runBootSweep, logger?: { info: Function } }} [deps]
227
+ * @returns {Promise<object>} the sweep envelope that was rendered.
228
+ */
229
+ export async function runBootSweepCli(
230
+ argv = process.argv.slice(2),
231
+ { runBootSweepImpl = runBootSweep, logger = Logger } = {},
232
+ ) {
180
233
  const { values } = parseArgs({
234
+ args: argv,
181
235
  options: {
182
236
  base: { type: 'string' },
183
237
  cwd: { type: 'string' },
@@ -192,11 +246,11 @@ async function main() {
192
246
  });
193
247
 
194
248
  if (values.help) {
195
- Logger.info(HELP);
196
- return;
249
+ logger.info(HELP);
250
+ return undefined;
197
251
  }
198
252
 
199
- const result = await runBootSweep({
253
+ const result = await runBootSweepImpl({
200
254
  cwd: typeof values.cwd === 'string' ? values.cwd : undefined,
201
255
  base: typeof values.base === 'string' ? values.base : undefined,
202
256
  include: Array.isArray(values.include) ? values.include : [],
@@ -206,10 +260,15 @@ async function main() {
206
260
  });
207
261
 
208
262
  if (values.json) {
209
- Logger.info(JSON.stringify(result, null, 2));
263
+ logger.info(JSON.stringify(result, null, 2));
210
264
  } else {
211
- Logger.info(buildSummaryLine(result));
265
+ logger.info(buildSummaryLine(result));
212
266
  }
267
+ return result;
268
+ }
269
+
270
+ async function main() {
271
+ await runBootSweepCli();
213
272
  }
214
273
 
215
274
  runAsCli(import.meta.url, main, {
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env node
2
+
3
+ // .agents/scripts/check-baseline-drift.js — scheduled full-scope baseline
4
+ // drift check (Story #4776).
5
+ //
6
+ // The three per-PR enforcement sites (close-validation, pre-push, CI) are all
7
+ // diff-scoped: they compare the files a branch touched against their committed
8
+ // baseline rows. A file nobody touches after its row is written is therefore
9
+ // never re-scored, so drift introduced indirectly — a dependency getting more
10
+ // complex, coverage moving underneath a method — stays invisible indefinitely.
11
+ //
12
+ // This CLI is the periodic full-scope counterpart. It re-scores every target
13
+ // directory through the same scorer that writes the baseline, prints a per-row
14
+ // before/after table for everything that moved beyond the gate's tolerance,
15
+ // and exits non-zero when it finds any — so a consumer can wire it as a
16
+ // scheduled CI job without wrapping it in verdict-parsing glue. Wiring it is
17
+ // deliberately consumer-side work; nothing in this repo schedules it.
18
+ //
19
+ // Exit codes:
20
+ // 0 — no drift (or every kind skipped: disabled gate, no baseline, no scorer)
21
+ // 1 — drift detected in at least one kind
22
+ // 2 — the check itself could not run
23
+
24
+ // Fail-fast if the framework's runtime deps are not installed — must be the
25
+ // first import so the check runs before any third-party-importing sibling
26
+ // module is evaluated (Story #3432).
27
+ import './lib/runtime-deps/ensure-installed.js';
28
+ import {
29
+ DRIFT_KINDS,
30
+ detectBaselineDrift,
31
+ formatDriftReport,
32
+ } from './lib/baselines/drift-detector.js';
33
+ import { runAsCli } from './lib/cli-utils.js';
34
+
35
+ export const HELP_TEXT = `Usage: node .agents/scripts/check-baseline-drift.js [options]
36
+
37
+ Re-score the configured baselines FULL-SCOPE and report every row whose
38
+ current score has drifted from its committed baseline by more than the
39
+ gate's tolerance — including files untouched by any recent diff, which the
40
+ diff-scoped gates structurally cannot see.
41
+
42
+ Options:
43
+ --gate <kind> Restrict to one kind (repeatable). Default: ${DRIFT_KINDS.join(', ')}.
44
+ --tolerance <n> Override the per-gate absolute tolerance.
45
+ --json Emit the machine-readable report instead of the table.
46
+ -h, --help Show this help.
47
+
48
+ Exit codes: 0 no drift · 1 drift detected · 2 the check could not run.
49
+
50
+ On drift, refresh with the printed \`*:update -- --full-scope\` command and
51
+ commit the result with a \`baseline-refresh:\` tagged subject (non-empty body).`;
52
+
53
+ /**
54
+ * Parse the CLI surface. Unknown flags are rejected so a typo'd `--gate`
55
+ * cannot silently widen a scheduled job's scope.
56
+ *
57
+ * @param {string[]} argv
58
+ * @returns {{ kinds: string[], tolerance: number|null, json: boolean }}
59
+ */
60
+ export function parseArgs(argv = []) {
61
+ const kinds = [];
62
+ let tolerance = null;
63
+ let json = false;
64
+ for (let i = 0; i < argv.length; i += 1) {
65
+ const arg = argv[i];
66
+ if (arg === '--gate' && argv[i + 1]) {
67
+ const kind = argv[i + 1];
68
+ if (!DRIFT_KINDS.includes(kind)) {
69
+ throw new Error(
70
+ `[drift] unknown --gate "${kind}"; expected one of ${DRIFT_KINDS.join(', ')}`,
71
+ );
72
+ }
73
+ kinds.push(kind);
74
+ i += 1;
75
+ } else if (arg === '--tolerance' && argv[i + 1]) {
76
+ const value = Number(argv[i + 1]);
77
+ if (!Number.isFinite(value)) {
78
+ throw new Error(
79
+ `[drift] --tolerance must be a number (got ${argv[i + 1]})`,
80
+ );
81
+ }
82
+ tolerance = value;
83
+ i += 1;
84
+ } else if (arg === '--json') {
85
+ json = true;
86
+ } else {
87
+ throw new Error(`[drift] unrecognised argument "${arg}"`);
88
+ }
89
+ }
90
+ return {
91
+ kinds: kinds.length > 0 ? kinds : [...DRIFT_KINDS],
92
+ tolerance,
93
+ json,
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Run the drift check and render it. Returns the process exit code rather
99
+ * than exiting, so the whole path is unit-testable.
100
+ *
101
+ * @param {{ argv?: string[], cwd?: string, detect?: typeof detectBaselineDrift }} opts
102
+ * @returns {Promise<{ exitCode: number, output: string }>}
103
+ */
104
+ export async function runCheckBaselineDrift({
105
+ argv = [],
106
+ cwd = process.cwd(),
107
+ detect = detectBaselineDrift,
108
+ } = {}) {
109
+ const args = parseArgs(argv);
110
+ const run = await detect({
111
+ kinds: args.kinds,
112
+ cwd,
113
+ tolerance: args.tolerance,
114
+ });
115
+ const output = args.json
116
+ ? JSON.stringify({ schemaVersion: '1', ...run }, null, 2)
117
+ : formatDriftReport(run);
118
+ return { exitCode: run.ok ? 0 : 1, output };
119
+ }
120
+
121
+ async function main() {
122
+ let result;
123
+ try {
124
+ result = await runCheckBaselineDrift({ argv: process.argv.slice(2) });
125
+ } catch (err) {
126
+ process.stdout.write(`${err?.message ?? String(err)}\n`);
127
+ process.exit(2);
128
+ return;
129
+ }
130
+ process.stdout.write(`${result.output}\n`);
131
+ process.exit(result.exitCode);
132
+ }
133
+
134
+ runAsCli(import.meta.url, main, {
135
+ source: 'check-baseline-drift',
136
+ usage: HELP_TEXT,
137
+ exitCode: 2,
138
+ });
@@ -23,6 +23,7 @@
23
23
  */
24
24
  import path from 'node:path';
25
25
  import { getChangedFiles } from './lib/changed-files.js';
26
+ import { isDirectInvocation } from './lib/cli-utils.js';
26
27
  import { getQuality, resolveConfig } from './lib/config-resolver.js';
27
28
  import {
28
29
  anyChangedUnderTargets,
@@ -35,7 +36,13 @@ import {
35
36
  import { Logger } from './lib/Logger.js';
36
37
  import { hasNpmScript, readPackageScripts } from './lib/npm-scripts.js';
37
38
 
38
- function parseArgs(argv) {
39
+ /**
40
+ * Parse the full `process.argv` (index 2 onward) into the capture options.
41
+ *
42
+ * @param {string[]} argv
43
+ * @returns {{ skipWhenNoCrapFiles: boolean, ref: string, cwd: string }}
44
+ */
45
+ export function parseArgs(argv) {
39
46
  const out = {
40
47
  skipWhenNoCrapFiles: false,
41
48
  ref: 'main',
@@ -50,13 +57,50 @@ function parseArgs(argv) {
50
57
  return out;
51
58
  }
52
59
 
53
- function main() {
54
- const args = parseArgs(process.argv);
55
- const config = resolveConfig({ cwd: args.cwd });
56
- const { crap, coverage } = getQuality(config);
60
+ /**
61
+ * The capture decision core, extracted from the CLI shell so the whole
62
+ * decision table (disabled gate, missing npm script, changed-file skip,
63
+ * freshness skip, capture + stamp) is reachable without spawning
64
+ * `npm run test:coverage` or touching the real filesystem.
65
+ *
66
+ * Every seam on the optional final `deps` parameter defaults to the real
67
+ * implementation (`.agents/rules/test-seams.md` rules 1-2, 4), so the CLI
68
+ * shell below and any production caller are unchanged.
69
+ *
70
+ * @param {string[]} [argv] Full `process.argv`-shaped array.
71
+ * @param {{
72
+ * resolveConfigImpl?: typeof resolveConfig,
73
+ * getQualityImpl?: typeof getQuality,
74
+ * getChangedFilesImpl?: typeof getChangedFiles,
75
+ * readPackageScriptsImpl?: typeof readPackageScripts,
76
+ * hasNpmScriptImpl?: typeof hasNpmScript,
77
+ * isCoverageFreshImpl?: typeof isCoverageFresh,
78
+ * runCaptureImpl?: typeof runCapture,
79
+ * computeContentDigestImpl?: typeof computeContentDigest,
80
+ * writeCaptureStampImpl?: typeof writeCaptureStamp,
81
+ * logger?: { info: Function, warn: Function, error: Function },
82
+ * }} [deps]
83
+ * @returns {number} process exit code
84
+ */
85
+ export function runCoverageCapture(argv = process.argv, deps = {}) {
86
+ const {
87
+ resolveConfigImpl = resolveConfig,
88
+ getQualityImpl = getQuality,
89
+ getChangedFilesImpl = getChangedFiles,
90
+ readPackageScriptsImpl = readPackageScripts,
91
+ hasNpmScriptImpl = hasNpmScript,
92
+ isCoverageFreshImpl = isCoverageFresh,
93
+ runCaptureImpl = runCapture,
94
+ computeContentDigestImpl = computeContentDigest,
95
+ writeCaptureStampImpl = writeCaptureStamp,
96
+ logger = Logger,
97
+ } = deps;
98
+ const args = parseArgs(argv);
99
+ const config = resolveConfigImpl({ cwd: args.cwd });
100
+ const { crap, coverage } = getQualityImpl(config);
57
101
 
58
102
  if (crap.enabled === false) {
59
- Logger.info('[coverage-capture] CRAP gate disabled — skipping capture.');
103
+ logger.info('[coverage-capture] CRAP gate disabled — skipping capture.');
60
104
  return 0;
61
105
  }
62
106
 
@@ -67,8 +111,8 @@ function main() {
67
111
  // defined it. Surface a one-line, fix-naming diagnostic instead of
68
112
  // spawning `npm run test:coverage` only to propagate npm's opaque
69
113
  // "Missing script" exit code.
70
- if (!hasNpmScript(readPackageScripts(args.cwd), 'test:coverage')) {
71
- Logger.error(
114
+ if (!hasNpmScriptImpl(readPackageScriptsImpl(args.cwd), 'test:coverage')) {
115
+ logger.error(
72
116
  '[coverage-capture] ✖ No "test:coverage" script in package.json. ' +
73
117
  'Add one (e.g. "test:coverage": "node --test --experimental-test-coverage") ' +
74
118
  'or disable the CRAP gate via delivery.quality.gates.crap.enabled=false.',
@@ -79,45 +123,45 @@ function main() {
79
123
  if (args.skipWhenNoCrapFiles) {
80
124
  let changed;
81
125
  try {
82
- changed = getChangedFiles({ ref: args.ref, cwd: args.cwd });
126
+ changed = getChangedFilesImpl({ ref: args.ref, cwd: args.cwd });
83
127
  } catch (err) {
84
128
  // A bad ref must not silently relax the gate. Fall through to the
85
129
  // freshness check so coverage still gets captured if needed.
86
- Logger.warn(
130
+ logger.warn(
87
131
  `[coverage-capture] ⚠ ${err?.message ?? err} — falling back to freshness check.`,
88
132
  );
89
133
  changed = null;
90
134
  }
91
135
  if (changed && !anyChangedUnderTargets(changed, crap.targetDirs)) {
92
- Logger.info(
136
+ logger.info(
93
137
  `[coverage-capture] No changed files under [${crap.targetDirs.join(', ')}] — skipping capture.`,
94
138
  );
95
139
  return 0;
96
140
  }
97
141
  }
98
142
 
99
- const freshness = isCoverageFresh({
143
+ const freshness = isCoverageFreshImpl({
100
144
  coveragePath: crap.coveragePath,
101
145
  targetDirs: crap.targetDirs,
102
146
  cwd: args.cwd,
103
147
  });
104
148
  if (freshness.fresh) {
105
- Logger.info(
149
+ logger.info(
106
150
  `[coverage-capture] Coverage at ${path.resolve(args.cwd, crap.coveragePath)} is ${freshness.reason} — skipping capture.`,
107
151
  );
108
152
  return 0;
109
153
  }
110
154
 
111
- Logger.info(
155
+ logger.info(
112
156
  `[coverage-capture] Coverage at ${crap.coveragePath} is ${freshness.reason}; running npm run test:coverage…`,
113
157
  );
114
- const code = runCapture({
158
+ const code = runCaptureImpl({
115
159
  cwd: args.cwd,
116
160
  timeoutMs: coverage?.timeoutMs,
117
- log: (m) => Logger.info(m),
161
+ log: (m) => logger.info(m),
118
162
  });
119
163
  if (code !== 0) {
120
- Logger.error(
164
+ logger.error(
121
165
  `[coverage-capture] ✖ npm run test:coverage exited ${code}. Fix failing tests or coverage-threshold breaches before re-running the CRAP gate.`,
122
166
  );
123
167
  return code;
@@ -127,24 +171,29 @@ function main() {
127
171
  // freshness checks are content-aware (mtime churn from branch switches no
128
172
  // longer invalidates). Best-effort — a missing stamp just means the next
129
173
  // check falls back to the mtime heuristic.
130
- const digest = computeContentDigest(args.cwd, crap.targetDirs);
174
+ const digest = computeContentDigestImpl(args.cwd, crap.targetDirs);
131
175
  if (
132
176
  digest &&
133
- writeCaptureStamp({
177
+ writeCaptureStampImpl({
134
178
  cwd: args.cwd,
135
179
  coveragePath: crap.coveragePath,
136
180
  digest,
137
181
  })
138
182
  ) {
139
- Logger.info('[coverage-capture] Wrote content-digest capture stamp.');
183
+ logger.info('[coverage-capture] Wrote content-digest capture stamp.');
140
184
  }
141
185
  return code;
142
186
  }
143
187
 
144
188
  // cli-opt-out: synchronous main returns an exit code that is forwarded via process.exit(code); runAsCli's async-main signature does not preserve the result code.
145
- try {
146
- process.exit(main());
147
- } catch (err) {
148
- Logger.error('[coverage-capture] unexpected error:', err);
149
- process.exit(1);
189
+ // The direct-invocation guard keeps `import`ing this module (from the unit
190
+ // tests that drive `runCoverageCapture` with injected seams) side-effect free;
191
+ // invoked as a CLI the behaviour — exit code and log lines — is unchanged.
192
+ if (isDirectInvocation(import.meta.url)) {
193
+ try {
194
+ process.exit(runCoverageCapture());
195
+ } catch (err) {
196
+ Logger.error('[coverage-capture] unexpected error:', err);
197
+ process.exit(1);
198
+ }
150
199
  }
@@ -82,19 +82,44 @@ export function parseArgv(argv) {
82
82
 
83
83
  /**
84
84
  * Probe and report. Exported for testing.
85
+ *
86
+ * The optional final `deps` parameter is the module's injectable seam
87
+ * (`.agents/rules/test-seams.md` rules 1-2): config resolution, provider
88
+ * construction, the probe itself, the renderer, and the log sink each default
89
+ * to the real implementation, so the CLI path and every production caller are
90
+ * unchanged. The pre-existing `injected*` fields on the first argument stay
91
+ * supported for callers already threading a resolved provider/config.
92
+ *
93
+ * @param {object} [args]
94
+ * @param {{
95
+ * resolveConfigImpl?: typeof resolveConfig,
96
+ * createProviderImpl?: typeof createProvider,
97
+ * recoverStoryImpl?: typeof recoverStory,
98
+ * renderRecoveryImpl?: typeof renderRecovery,
99
+ * logger?: { info: Function },
100
+ * }} [deps]
85
101
  */
86
- export async function runDeliverRecover({
87
- storyId: storyIdParam,
88
- cwd: cwdParam,
89
- json: jsonParam,
90
- reprobe: reprobeParam,
91
- argv,
92
- injectedProvider,
93
- injectedConfig,
94
- injectedGh,
95
- injectedGitSpawn,
96
- injectedSleepFn,
97
- } = {}) {
102
+ export async function runDeliverRecover(
103
+ {
104
+ storyId: storyIdParam,
105
+ cwd: cwdParam,
106
+ json: jsonParam,
107
+ reprobe: reprobeParam,
108
+ argv,
109
+ injectedProvider,
110
+ injectedConfig,
111
+ injectedGh,
112
+ injectedGitSpawn,
113
+ injectedSleepFn,
114
+ } = {},
115
+ {
116
+ resolveConfigImpl = resolveConfig,
117
+ createProviderImpl = createProvider,
118
+ recoverStoryImpl = recoverStory,
119
+ renderRecoveryImpl = renderRecovery,
120
+ logger = Logger,
121
+ } = {},
122
+ ) {
98
123
  const parsed =
99
124
  storyIdParam !== undefined
100
125
  ? {
@@ -106,7 +131,7 @@ export async function runDeliverRecover({
106
131
  : parseArgv(argv ?? process.argv.slice(2));
107
132
 
108
133
  if (parsed.help) {
109
- Logger.info(HELP);
134
+ logger.info(HELP);
110
135
  return { success: true, result: null };
111
136
  }
112
137
  if (!Number.isInteger(parsed.storyId) || parsed.storyId <= 0) {
@@ -116,10 +141,10 @@ export async function runDeliverRecover({
116
141
  }
117
142
 
118
143
  const cwd = parsed.cwd ?? PROJECT_ROOT;
119
- const config = injectedConfig || resolveConfig({ cwd });
120
- const provider = injectedProvider || createProvider(config);
144
+ const config = injectedConfig || resolveConfigImpl({ cwd });
145
+ const provider = injectedProvider || createProviderImpl(config);
121
146
 
122
- const recovery = await recoverStory({
147
+ const recovery = await recoverStoryImpl({
123
148
  storyId: parsed.storyId,
124
149
  cwd,
125
150
  provider,
@@ -130,8 +155,10 @@ export async function runDeliverRecover({
130
155
  ...(injectedSleepFn ? { sleepFn: injectedSleepFn } : {}),
131
156
  });
132
157
 
133
- Logger.info(
134
- parsed.json ? JSON.stringify(recovery, null, 2) : renderRecovery(recovery),
158
+ logger.info(
159
+ parsed.json
160
+ ? JSON.stringify(recovery, null, 2)
161
+ : renderRecoveryImpl(recovery),
135
162
  );
136
163
  return { success: true, result: recovery };
137
164
  }