mandrel 2.15.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 (69) hide show
  1. package/.agents/docs/configuration.md +1 -0
  2. package/.agents/docs/quality-gates.md +137 -0
  3. package/.agents/docs/workflows.md +2 -1
  4. package/.agents/schemas/agentrc.schema.json +6 -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/scripts/acceptance-eval.js +52 -12
  8. package/.agents/scripts/audit-to-stories.js +92 -25
  9. package/.agents/scripts/boot-sweep.js +28 -6
  10. package/.agents/scripts/check-baseline-drift.js +138 -0
  11. package/.agents/scripts/coverage-capture.js +74 -25
  12. package/.agents/scripts/deliver-light.js +31 -3
  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/process.js +61 -15
  27. package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
  28. package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
  29. package/.agents/scripts/lib/close-validation/runner.js +68 -0
  30. package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
  31. package/.agents/scripts/lib/config/quality.js +40 -0
  32. package/.agents/scripts/lib/coverage-utils.js +92 -9
  33. package/.agents/scripts/lib/crap-engine.js +113 -23
  34. package/.agents/scripts/lib/crap-utils.js +159 -93
  35. package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
  36. package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
  37. package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
  38. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
  39. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
  40. package/.agents/scripts/lib/orchestration/complexity-gate.js +307 -89
  41. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
  42. package/.agents/scripts/lib/orchestration/light-suitability.js +31 -9
  43. package/.agents/scripts/lib/orchestration/plan-context.js +190 -10
  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 +87 -13
  46. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +38 -15
  47. package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
  48. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
  49. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
  50. package/.agents/scripts/lib/stdio-flush.js +71 -0
  51. package/.agents/scripts/lib/transpile.js +133 -6
  52. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
  53. package/.agents/scripts/lib/workers/crap-worker.js +49 -76
  54. package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
  55. package/.agents/scripts/nav-registry-diff.js +30 -8
  56. package/.agents/scripts/plan-context.js +4 -1
  57. package/.agents/scripts/plan-run-epilogue.js +27 -11
  58. package/.agents/scripts/resolve-doc-tiers.js +18 -8
  59. package/.agents/scripts/single-story-close.js +9 -92
  60. package/.agents/scripts/update-crap-baseline.js +13 -0
  61. package/.agents/workflows/helpers/deliver-light.md +34 -8
  62. package/.agents/workflows/helpers/plan-reference.md +27 -6
  63. package/.agents/workflows/plan.md +4 -2
  64. package/.agents/workflows/prototype.md +104 -0
  65. package/README.md +14 -6
  66. package/docs/CHANGELOG.md +41 -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
@@ -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
  }
@@ -79,18 +79,29 @@ import { createProvider } from './lib/provider-factory.js';
79
79
  const HELP = `\
80
80
  Usage:
81
81
  deliver-light.js --prompt <text> [--creates csv] [--refactors csv]
82
- [--acceptance n] [--route lite|full] [--reason <text>]
82
+ [--acceptance n] [--kinds csv] [--magnitude m]
83
+ [--uncertainty u] [--route lite|full] [--reason <text>]
83
84
  [--amends '#id'] [--yes]
84
85
  deliver-light.js --backstop --story <id>
85
86
 
86
87
  The thin /deliver-light entry point: suitability gate → inline receipt Story →
87
88
  the same single-story-init.js / single-story-close.js engine /deliver uses.
88
89
 
90
+ The gate judges EFFORT and RISK, not artifact counts: N instances of one
91
+ mechanical edit is one kind at N sites. It rejects only clearly-epic work; the
92
+ --backstop pass enforces size against the actual diff.
93
+
89
94
  Gate options:
90
95
  --prompt <text> Operator prompt describing the change. Required for the gate.
91
96
  --creates <csv> Predicted NEW file paths (comma-separated).
92
97
  --refactors <csv> Predicted edited/existing file paths (comma-separated).
93
- --acceptance <n> Predicted acceptance-criteria count (default 1).
98
+ --acceptance <n> Predicted acceptance-criteria count (default 1). Not capped.
99
+ --kinds <csv> Distinct KINDS of change (default: one per assumption, so
100
+ N same-shaped edits count once).
101
+ --magnitude <m> Coarse effort bucket: trivial | moderate | substantial
102
+ (default moderate; substantial routes to /plan).
103
+ --uncertainty <u> determined (the request fixes the shape) | needs-design
104
+ (default determined; needs-design routes to /plan).
94
105
  --route <r> Ledgered model verdict route: lite | full.
95
106
  --reason <text> Recorded reason for a lite verdict (required for lite).
96
107
  --amends <#id> Mark this as an amendment of an existing issue.
@@ -166,17 +177,25 @@ export function synthesizeAcceptance(count) {
166
177
  * creates?: string[],
167
178
  * refactors?: string[],
168
179
  * acceptance?: number,
180
+ * kinds?: string[],
181
+ * magnitude?: string,
182
+ * uncertainty?: string,
169
183
  * route?: string,
170
184
  * reason?: string,
171
185
  * yes?: boolean,
172
186
  * injectedRules?: object,
173
- * }} args
187
+ * }} args `kinds` / `magnitude` / `uncertainty` are the declared effort-and-risk
188
+ * axes the gate judges (Story #4764); omitting them declares no signal, not a
189
+ * small one — an unrecognized bucket fails closed.
174
190
  * @returns {{ action: string, suitability: object, outcome: object }}
175
191
  */
176
192
  export function runLightGate({
177
193
  creates = [],
178
194
  refactors = [],
179
195
  acceptance,
196
+ kinds,
197
+ magnitude,
198
+ uncertainty,
180
199
  route,
181
200
  reason,
182
201
  yes = false,
@@ -186,6 +205,9 @@ export function runLightGate({
186
205
  const suitability = deriveLightSuitability({
187
206
  predictedChanges,
188
207
  predictedAcceptance: synthesizeAcceptance(acceptance),
208
+ predictedKinds: kinds,
209
+ predictedMagnitude: magnitude,
210
+ predictedUncertainty: uncertainty,
189
211
  verdict: { route, reason },
190
212
  injectedRules,
191
213
  });
@@ -355,6 +377,9 @@ export async function runGateMode(values, deps = {}) {
355
377
  acceptance: values.acceptance
356
378
  ? Number.parseInt(String(values.acceptance), 10)
357
379
  : 1,
380
+ kinds: parseCsvPaths(values.kinds),
381
+ magnitude: values.magnitude,
382
+ uncertainty: values.uncertainty,
358
383
  route: values.route,
359
384
  reason: values.reason,
360
385
  yes: values.yes === true,
@@ -417,6 +442,9 @@ async function main() {
417
442
  creates: { type: 'string' },
418
443
  refactors: { type: 'string' },
419
444
  acceptance: { type: 'string' },
445
+ kinds: { type: 'string' },
446
+ magnitude: { type: 'string' },
447
+ uncertainty: { type: 'string' },
420
448
  route: { type: 'string' },
421
449
  reason: { type: 'string' },
422
450
  amends: { type: 'string' },
@@ -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
  }
@@ -39,8 +39,44 @@ const progress = Logger.createProgress('drain-pending-cleanup', {
39
39
  stderr: false,
40
40
  });
41
41
 
42
- async function main() {
42
+ /**
43
+ * The drain core, extracted from the CLI shell so the whole decision table —
44
+ * empty manifest, dry-run report, drain + escalate reporting — is reachable
45
+ * without a real worktree ledger or a real process kill.
46
+ *
47
+ * Every seam on the optional final `deps` parameter defaults to the real
48
+ * implementation (`.agents/rules/test-seams.md` rules 1-2, 4), so `main` and
49
+ * every production caller are unchanged.
50
+ *
51
+ * @param {string[]} [argv]
52
+ * @param {{
53
+ * resolveConfigImpl?: typeof resolveConfig,
54
+ * readManifestImpl?: typeof readManifest,
55
+ * findHoldersInPathImpl?: typeof findHoldersInPath,
56
+ * forceDrainImpl?: typeof forceDrainPendingCleanup,
57
+ * gitImpl?: typeof gitUtils,
58
+ * projectRoot?: string,
59
+ * progressImpl?: (phase: string, message: string) => void,
60
+ * logger?: { error: Function },
61
+ * }} [deps]
62
+ * @returns {Promise<{ drained: number[], remaining: number } | { remaining: number }>}
63
+ */
64
+ export async function runDrainPendingCleanup(
65
+ argv = process.argv.slice(2),
66
+ deps = {},
67
+ ) {
68
+ const {
69
+ resolveConfigImpl = resolveConfig,
70
+ readManifestImpl = readManifest,
71
+ findHoldersInPathImpl = findHoldersInPath,
72
+ forceDrainImpl = forceDrainPendingCleanup,
73
+ gitImpl = gitUtils,
74
+ projectRoot = PROJECT_ROOT,
75
+ progressImpl = progress,
76
+ logger = Logger,
77
+ } = deps;
43
78
  const { values } = parseArgs({
79
+ args: argv,
44
80
  options: {
45
81
  escalate: { type: 'boolean', default: true },
46
82
  'dry-run': { type: 'boolean', default: false },
@@ -49,20 +85,23 @@ async function main() {
49
85
  strict: false,
50
86
  });
51
87
 
52
- const config = resolveConfig();
88
+ const config = resolveConfigImpl();
53
89
  const wtConfig = config.delivery?.worktreeIsolation;
54
90
  const worktreeRoot = path.resolve(
55
- PROJECT_ROOT,
91
+ projectRoot,
56
92
  values['worktree-root'] ?? wtConfig?.root ?? '.worktrees',
57
93
  );
58
94
 
59
- const before = readManifest(worktreeRoot);
95
+ const before = readManifestImpl(worktreeRoot);
60
96
  if (before.length === 0) {
61
- progress('SCAN', 'pending-cleanup manifest is empty — nothing to drain.');
62
- return;
97
+ progressImpl(
98
+ 'SCAN',
99
+ 'pending-cleanup manifest is empty — nothing to drain.',
100
+ );
101
+ return { remaining: 0 };
63
102
  }
64
103
 
65
- progress(
104
+ progressImpl(
66
105
  'SCAN',
67
106
  `pending-cleanup manifest has ${before.length} entry(ies): ${before
68
107
  .map((e) => `story-${e.storyId}(attempts=${e.attempts ?? 0})`)
@@ -71,8 +110,8 @@ async function main() {
71
110
 
72
111
  if (values['dry-run']) {
73
112
  for (const entry of before) {
74
- const holders = findHoldersInPath(entry.path);
75
- progress(
113
+ const holders = findHoldersInPathImpl(entry.path);
114
+ progressImpl(
76
115
  'DRY-RUN',
77
116
  `story-${entry.storyId} path=${entry.path} holders=${holders.length}` +
78
117
  (holders.length > 0
@@ -80,23 +119,23 @@ async function main() {
80
119
  : ''),
81
120
  );
82
121
  }
83
- return;
122
+ return { remaining: before.length };
84
123
  }
85
124
 
86
- const result = await forceDrainPendingCleanup({
87
- repoRoot: PROJECT_ROOT,
125
+ const result = await forceDrainImpl({
126
+ repoRoot: projectRoot,
88
127
  worktreeRoot,
89
- git: gitUtils,
128
+ git: gitImpl,
90
129
  escalate: values.escalate,
91
130
  logger: {
92
- info: (m) => progress('DRAIN', m),
93
- warn: (m) => progress('DRAIN', `⚠️ ${m}`),
94
- error: (m) => Logger.error(`[drain-pending-cleanup] ${m}`),
131
+ info: (m) => progressImpl('DRAIN', m),
132
+ warn: (m) => progressImpl('DRAIN', `⚠️ ${m}`),
133
+ error: (m) => logger.error(`[drain-pending-cleanup] ${m}`),
95
134
  },
96
135
  });
97
136
 
98
137
  if (result.drained.length > 0) {
99
- progress(
138
+ progressImpl(
100
139
  'DRAIN',
101
140
  `✅ drained ${result.drained.length} entry(ies): ${result.drained
102
141
  .map((id) => `story-${id}`)
@@ -107,10 +146,10 @@ async function main() {
107
146
  const summary = result.escalated
108
147
  .map((id) => `story-${id}=[${(result.killedPids[id] ?? []).join(',')}]`)
109
148
  .join(', ');
110
- progress('ESCALATE', `terminated holders: ${summary}`);
149
+ progressImpl('ESCALATE', `terminated holders: ${summary}`);
111
150
  }
112
151
  if (result.noHolders && result.noHolders.length > 0) {
113
- progress(
152
+ progressImpl(
114
153
  'ESCALATE',
115
154
  `⚠️ no user-mode holders for: ${result.noHolders
116
155
  .map((id) => `story-${id}`)
@@ -120,7 +159,7 @@ async function main() {
120
159
  );
121
160
  }
122
161
  if (result.persistent.length > 0) {
123
- progress(
162
+ progressImpl(
124
163
  'PERSIST',
125
164
  `⚠️ persistent-lock remains on: ${result.persistent
126
165
  .map((id) => `story-${id}`)
@@ -128,7 +167,7 @@ async function main() {
128
167
  );
129
168
  }
130
169
  if (result.stillPending.length > 0) {
131
- progress(
170
+ progressImpl(
132
171
  'STILL-PENDING',
133
172
  `⚠️ still-pending (below threshold): ${result.stillPending
134
173
  .map((id) => `story-${id}`)
@@ -136,12 +175,17 @@ async function main() {
136
175
  );
137
176
  }
138
177
 
139
- const after = readManifest(worktreeRoot);
140
- progress(
178
+ const after = readManifestImpl(worktreeRoot);
179
+ progressImpl(
141
180
  'DONE',
142
181
  `pending-cleanup manifest now has ${after.length} entry(ies). ` +
143
182
  `Drained=${result.drained.length}, escalated=${result.escalated.length}, persistent=${result.persistent.length}.`,
144
183
  );
184
+ return { drained: result.drained, remaining: after.length };
185
+ }
186
+
187
+ async function main() {
188
+ await runDrainPendingCleanup();
145
189
  }
146
190
 
147
191
  runAsCli(import.meta.url, main, {