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
@@ -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 ` +
@@ -50,24 +50,34 @@ export function parseArgv(argv = []) {
50
50
  * Top-level CLI entry. Exported so tests can drive it against a fixture root
51
51
  * with an injected sink and config.
52
52
  *
53
+ * The optional final `deps` parameter is the module's injectable seam
54
+ * (`.agents/rules/test-seams.md` rules 1-2): every entry defaults to the real
55
+ * implementation, so the CLI path below — and any production caller — needs no
56
+ * configuration change.
57
+ *
53
58
  * @param {{
54
59
  * argv?: string[],
55
60
  * config?: object,
56
61
  * root?: string,
57
62
  * stdout?: { write: (s: string) => void },
58
63
  * }} [opts]
64
+ * @param {{
65
+ * resolveConfigImpl?: typeof resolveConfig,
66
+ * resolveDocTiersImpl?: typeof resolveDocTiers,
67
+ * }} [deps]
59
68
  * @returns {Promise<number>} always 0
60
69
  */
61
- export async function runCli({
62
- argv = process.argv.slice(2),
63
- config,
64
- root,
65
- stdout = process.stdout,
66
- } = {}) {
70
+ export async function runCli(
71
+ { argv = process.argv.slice(2), config, root, stdout = process.stdout } = {},
72
+ {
73
+ resolveConfigImpl = resolveConfig,
74
+ resolveDocTiersImpl = resolveDocTiers,
75
+ } = {},
76
+ ) {
67
77
  const { rootPath } = parseArgv(argv);
68
- const resolvedConfig = config ?? resolveConfig();
78
+ const resolvedConfig = config ?? resolveConfigImpl();
69
79
  const resolvedRoot = root ?? rootPath ?? PROJECT_ROOT;
70
- const result = resolveDocTiers(resolvedConfig, { root: resolvedRoot });
80
+ const result = resolveDocTiersImpl(resolvedConfig, { root: resolvedRoot });
71
81
  stdout.write(`${JSON.stringify(result, null, 2)}\n`);
72
82
  return 0;
73
83
  }
@@ -90,6 +90,10 @@ import { runAsCli } from './lib/cli-utils.js';
90
90
  import { formatCliError } from './lib/error-redactor.js';
91
91
  import { Logger } from './lib/Logger.js';
92
92
  import { emitTerminalFriction } from './lib/observability/runtime-friction.js';
93
+ import {
94
+ failedTerminalFor,
95
+ gatesForFailedPhase,
96
+ } from './lib/orchestration/single-story-close/failed-terminal.js';
93
97
  import { enableAutoMergeWith } from './lib/orchestration/single-story-close/phases/auto-merge.js';
94
98
  import {
95
99
  buildSyncFailureCommentBody,
@@ -102,10 +106,8 @@ import {
102
106
  } from './lib/orchestration/single-story-close/phases/code-review.js';
103
107
  import { ensurePullRequestWith } from './lib/orchestration/single-story-close/phases/pull-request.js';
104
108
  import {
105
- buildTerminalEnvelope,
106
109
  emitTerminalEnvelope,
107
110
  exitCodeForTerminal,
108
- NEXT_COMMANDS,
109
111
  } from './lib/orchestration/story-deliver-terminal.js';
110
112
 
111
113
  // Story #2990 moved the `gh`-spawn boundary into the `lib/gh-exec.js`
@@ -118,9 +120,13 @@ export const enableAutoMerge = enableAutoMergeWith;
118
120
 
119
121
  // Re-export pure helpers verbatim — they don't touch `execFileSync`
120
122
  // or any URL-mocked module, so the phase exports work unmodified.
123
+ // `gatesForFailedPhase` now lives beside the envelope it feeds
124
+ // (`single-story-close/failed-terminal.js`); it is re-exported here so the
125
+ // CLI's public surface is unchanged by that move.
121
126
  export {
122
127
  buildStoryReviewCrossRefBody,
123
128
  buildSyncFailureCommentBody,
129
+ gatesForFailedPhase,
124
130
  handleSyncFailure,
125
131
  parsePrNumber,
126
132
  runStoryScopeReview,
@@ -134,95 +140,6 @@ export async function runSingleStoryClose(opts) {
134
140
  return mod.runSingleStoryClose(opts);
135
141
  }
136
142
 
137
- /**
138
- * The close pipeline's phase order, as `setPhase` walks it. Only used to
139
- * decide whether a gate had already run when a later phase died.
140
- */
141
- const PHASE_ORDER = Object.freeze([
142
- 'init',
143
- 'wrong-tree-guard',
144
- 'close-validation',
145
- 'base-sync',
146
- 'push',
147
- 'pull-request',
148
- 'code-review',
149
- 'auto-merge',
150
- 'confirm-merge',
151
- 'post-land',
152
- 'done',
153
- ]);
154
-
155
- /** Each reported gate and the pipeline phase that decides it. */
156
- const GATE_PHASES = Object.freeze([
157
- ['validation', 'close-validation'],
158
- ['baseSync', 'base-sync'],
159
- ['codeReview', 'code-review'],
160
- ]);
161
-
162
- /**
163
- * Report every gate's outcome for a run that died at `phase`.
164
- *
165
- * The schema's contract: "A gate the run skipped … reports `skipped` rather
166
- * than being omitted, so a missing gate is never mistaken for a passing one."
167
- * The previous shape named only the gate that died and omitted the rest
168
- * entirely — exactly the ambiguity the contract forbids.
169
- *
170
- * Reconstructed from the phase order, which is sound because the pipeline is
171
- * strictly sequential: reaching phase N means every gate before it completed.
172
- * A gate whose phase the run never reached is `skipped`; one the operator
173
- * turned off via `--skip-validation` / `--skip-sync` is `skipped` too (it did
174
- * not pass — it never ran).
175
- *
176
- * @param {string} phase The phase the run died in.
177
- * @param {{ skipValidation?: boolean, skipSync?: boolean }} args Parsed CLI args.
178
- * @returns {Record<string, 'passed'|'failed'|'skipped'>}
179
- */
180
- export function gatesForFailedPhase(phase, args = {}) {
181
- const skipped = { validation: args.skipValidation, baseSync: args.skipSync };
182
- const failedAt = PHASE_ORDER.indexOf(phase);
183
- const gates = {};
184
- for (const [gate, gatePhase] of GATE_PHASES) {
185
- const at = PHASE_ORDER.indexOf(gatePhase);
186
- if (gatePhase === phase) gates[gate] = 'failed';
187
- else if (failedAt < 0 || at > failedAt) gates[gate] = 'skipped';
188
- else gates[gate] = skipped[gate] ? 'skipped' : 'passed';
189
- }
190
- return gates;
191
- }
192
-
193
- /**
194
- * Build the `failed` terminal for a phase that crashed.
195
- *
196
- * The runner deliberately throws rather than returning a failure (a red gate
197
- * must not look like a return value), so without this the most common
198
- * non-happy ending — a failing close-validation gate — would emit **no
199
- * envelope at all**, exiting 1 with only a stderr line while the workflow
200
- * docs promise the agent a `failed` envelope naming the phase. Every close
201
- * invocation emits exactly one envelope; this is the path that keeps that
202
- * true when a phase dies.
203
- *
204
- * `err.closePhase` is tagged by the runner's phase tracker.
205
- *
206
- * @param {unknown} err
207
- * @returns {object|null} A validated envelope, or null when even the story id
208
- * is unknown (a usage error — there is nothing to report an envelope about).
209
- */
210
- function failedTerminalFor(err) {
211
- const phase = err?.closePhase ?? 'init';
212
- const args = parseSprintArgs();
213
- const storyId = Number(args.storyId);
214
- if (!Number.isInteger(storyId) || storyId <= 0) return null;
215
- return buildTerminalEnvelope({
216
- storyId,
217
- status: 'failed',
218
- phase,
219
- gates: gatesForFailedPhase(phase, args),
220
- failure: { reason: String(err?.message ?? err) },
221
- nextCommand: NEXT_COMMANDS.recover(storyId),
222
- elapsedSeconds: 0,
223
- });
224
- }
225
-
226
143
  /**
227
144
  * CLI entry — resolves the process exit code from the terminal envelope's
228
145
  * status rather than from a thrown/not-thrown distinction, so `pending`
@@ -234,7 +151,7 @@ async function main() {
234
151
  const outcome = await runSingleStoryClose();
235
152
  return exitCodeForTerminal(outcome?.terminal ?? { status: 'failed' });
236
153
  } catch (err) {
237
- const terminal = failedTerminalFor(err);
154
+ const terminal = failedTerminalFor(err, parseSprintArgs());
238
155
  if (!terminal) throw err;
239
156
  // Mirror runAsCli's default error line (which this catch pre-empts) so the
240
157
  // human-facing failure text is unchanged, then emit the envelope.
@@ -777,7 +777,7 @@ export async function runSingleStoryInit({
777
777
  label: 'STORY INIT RESULT',
778
778
  result,
779
779
  scope: storyId,
780
- logDir: path.join(cwd, 'temp', 'orchestration'),
780
+ config,
781
781
  summary: {
782
782
  storyId,
783
783
  storyBranch,
@@ -31,6 +31,7 @@
31
31
  import path from 'node:path';
32
32
  import { parseArgs } from 'node:util';
33
33
  import { runAsCli } from './lib/cli-utils.js';
34
+ import { resolveConfig } from './lib/config-resolver.js';
34
35
  import { syncBranchFromBase } from './lib/git/sync-from-base.js';
35
36
  import { gitSpawn, gitSync } from './lib/git-utils.js';
36
37
  import { Logger } from './lib/Logger.js';
@@ -95,11 +96,15 @@ export async function runSyncBranchFromBase(opts = {}) {
95
96
  });
96
97
 
97
98
  // Story #4685 — full detail to a temp log; emit a single summary line.
99
+ // Story #4794 — resolve the config so the log honours `project.paths.tempRoot`
100
+ // instead of the hardcoded `<cwd>/temp` this used to join. `resolveConfig`
101
+ // degrades to the framework defaults when no `.agentrc.json` is present, so
102
+ // a zero-config invocation needs no guard here.
98
103
  emitTerseResult({
99
104
  label: 'SYNC RESULT',
100
105
  result,
101
106
  scope: branch,
102
- logDir: path.join(cwd, 'temp', 'orchestration'),
107
+ config: resolveConfig({ cwd }),
103
108
  summary: { branch, base, synced: result.synced, kind: result.kind },
104
109
  });
105
110
 
@@ -13,6 +13,7 @@ import {
13
13
  } from './lib/config-resolver.js';
14
14
  import { loadCoverage } from './lib/coverage-utils.js';
15
15
  import {
16
+ checkResolutionFloor,
16
17
  resolveEscomplexVersion,
17
18
  resolveTsTranspilerVersion,
18
19
  scanAndScore,
@@ -71,6 +72,7 @@ async function main() {
71
72
  const crap = getQuality(config).crap;
72
73
  const targetDirs = Array.isArray(crap.targetDirs) ? crap.targetDirs : [];
73
74
  const requireCoverage = crap.requireCoverage !== false;
75
+ const minMethodResolutionRate = crap.minMethodResolutionRate ?? 0.75;
74
76
  const coveragePath =
75
77
  args.coveragePath ?? crap.coveragePath ?? 'coverage/coverage-final.json';
76
78
  const baselinePath = args.baselinePath ?? getBaselines(config).crap.path;
@@ -117,6 +119,7 @@ async function main() {
117
119
  scannedFiles,
118
120
  skippedFilesNoCoverage,
119
121
  skippedMethodsNoCoverage,
122
+ resolution,
120
123
  } = await scanAndScore({
121
124
  targetDirs,
122
125
  coverage,
@@ -137,6 +140,16 @@ async function main() {
137
140
  `[CRAP] Skipped ${skippedMethodsNoCoverage} method(s) whose per-method coverage was unresolved.`,
138
141
  );
139
142
  }
143
+ if (resolution) {
144
+ Logger.info(
145
+ `[CRAP] Method resolution: ${resolution.resolvedMethods}/${resolution.joinableMethods} ` +
146
+ `(${(resolution.rate * 100).toFixed(1)}%) in files with coverage.`,
147
+ );
148
+ }
149
+ // Fail closed BEFORE the service persists anything — a thin baseline is
150
+ // never written and then apologised for.
151
+ const refusal = checkResolutionFloor(resolution, minMethodResolutionRate);
152
+ if (refusal) throw new Error(refusal);
140
153
 
141
154
  return (rows ?? []).filter(
142
155
  (r) => typeof r?.crap === 'number' && Number.isFinite(r.crap),
package/README.md CHANGED
@@ -181,10 +181,14 @@ dimensions, run model, and how to benchmark a new version.
181
181
 
182
182
  ## Contributors
183
183
 
184
- Only `.agents/` is distributed to consumersit ships inside the
185
- `mandrel` npm package and is materialized into a consumer's
186
- `./.agents/` directory by `mandrel sync`. Everything else in this
187
- repository is internal development tooling.
184
+ The published `mandrel` package ships three directories `.agents/`, `bin/`,
185
+ and `lib/` (see the `files` array in [`package.json`](package.json)).
186
+ `.agents/` is the payload `mandrel sync` materializes into a consumer's
187
+ `./.agents/` directory; `bin/mandrel.js` and its `lib/` implementation stay
188
+ inside `node_modules/mandrel/` and back the `npx mandrel …` CLI used
189
+ throughout this README. Everything else in this repository — `docs/`,
190
+ `tests/`, `.github/`, the root tooling configs — is internal development
191
+ tooling and is not published.
188
192
 
189
193
  Common commands while developing the framework itself:
190
194
 
@@ -204,8 +208,12 @@ Deeper reference material lives in `docs/` rather than inline here:
204
208
  - [`.agents/docs/workflows.md`](.agents/docs/workflows.md) — slash-command
205
209
  index (auto-generated from the workflow set).
206
210
  - [`docs/CHANGELOG.md`](docs/CHANGELOG.md) — release history.
207
- - [`AGENTS.md`](AGENTS.md) — repository onboarding, the single-package release
208
- topology, PAT / npm-token setup, and major-version policy. Releases are
211
+ - [`AGENTS.md`](AGENTS.md) — the repository-level orientation pointer; it
212
+ links on to [`docs/onboarding.md`](docs/onboarding.md) for the layout,
213
+ commands, and development standards.
214
+ - [`docs/release-operations.md`](docs/release-operations.md) — the Release
215
+ Checklist, the Install Matrix release gate, the single-package release
216
+ topology, PAT / npm-token setup, and the major-version policy. Releases are
209
217
  automated by `release-please`: land Conventional Commits on `main` and it
210
218
  opens a combined `chore: release main` PR that squash-merges itself once
211
219
  CI is green, tags `main`, and publishes `mandrel` to npm.
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,42 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [2.18.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.17.0...mandrel-v2.18.0) (2026-07-26)
6
+
7
+
8
+ ### Added
9
+
10
+ * **temp:** auto-purge merged Stories' spent temp artifacts behind an allowlist, keeping signals ([#4794](https://github.com/dsj1984/mandrel/issues/4794)) ([#4795](https://github.com/dsj1984/mandrel/issues/4795)) ([d69d985](https://github.com/dsj1984/mandrel/commit/d69d9858511658e0ec0336db0cf8aa65d725637b))
11
+
12
+
13
+ ### Chores
14
+
15
+ * **release:** cut the 2.18.0 release missed by an unparseable squash subject ([#4796](https://github.com/dsj1984/mandrel/issues/4796)) ([8ccff57](https://github.com/dsj1984/mandrel/commit/8ccff574d44519595e04fc0f3597cb4dcd005436))
16
+
17
+ ## [2.17.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.16.0...mandrel-v2.17.0) (2026-07-26)
18
+
19
+
20
+ ### Added
21
+
22
+ * **crap:** wire the baseline-refresh projection layer and add CRAP freshness + full-scope drift detection ([#4776](https://github.com/dsj1984/mandrel/issues/4776)) ([#4778](https://github.com/dsj1984/mandrel/issues/4778)) ([8c49a12](https://github.com/dsj1984/mandrel/commit/8c49a12be6ca5ecaca60d8f0bc868f47c9a02534))
23
+
24
+
25
+ ### Fixed
26
+
27
+ * **audit-to-stories:** anchor findings to their real primary file (refs [#4781](https://github.com/dsj1984/mandrel/issues/4781)) ([#4782](https://github.com/dsj1984/mandrel/issues/4782)) ([a5794c0](https://github.com/dsj1984/mandrel/commit/a5794c02c3443c39ce116699c311b4cdd8001788))
28
+ * **cli:** drain stdio before exit and settle the audit fan-out per dimension (refs [#4783](https://github.com/dsj1984/mandrel/issues/4783)) ([#4787](https://github.com/dsj1984/mandrel/issues/4787)) ([3422fd5](https://github.com/dsj1984/mandrel/commit/3422fd564773d0e79ac4d16af5bbc5414d06c65b))
29
+ * **crap:** join per-method coverage in original-source coordinates — TS scoring resolves ~5% of methods ([#4775](https://github.com/dsj1984/mandrel/issues/4775)) ([#4777](https://github.com/dsj1984/mandrel/issues/4777)) ([1464c75](https://github.com/dsj1984/mandrel/commit/1464c75c061960aaa9ee1af88175d9706b044c26))
30
+ * **deliver:** keep the terminal envelope alive after the worktree reap (refs [#4784](https://github.com/dsj1984/mandrel/issues/4784)) ([#4791](https://github.com/dsj1984/mandrel/issues/4791)) ([907b7bd](https://github.com/dsj1984/mandrel/commit/907b7bdb951a70a7155e5a9a50afdbea7a81d6a6))
31
+ * **deps:** decouple the js-yaml override from markdownlint-cli2, close Node-engine drift, and re-enable knip dependency rules ([#4784](https://github.com/dsj1984/mandrel/issues/4784)) ([#4788](https://github.com/dsj1984/mandrel/issues/4788)) ([9ed687b](https://github.com/dsj1984/mandrel/commit/9ed687bead90d3e0edf76e7d0e700b0f51e8cb5d))
32
+ * **docs:** re-sync the reference docs to the contracts the code actually implements ([#4785](https://github.com/dsj1984/mandrel/issues/4785)) ([#4790](https://github.com/dsj1984/mandrel/issues/4790)) ([00d9270](https://github.com/dsj1984/mandrel/commit/00d92708b3d3ac0978c073c5a31fd6a80a25767e))
33
+ * **docs:** supersede the stale ADR chain in place and archive the retired pattern history ([#4786](https://github.com/dsj1984/mandrel/issues/4786)) ([#4789](https://github.com/dsj1984/mandrel/issues/4789)) ([5ba2e34](https://github.com/dsj1984/mandrel/commit/5ba2e3486973b87603fd3f2f23f750f18e75e240))
34
+ * **git-cleanup:** report the refs the prune phase actually dropped (refs [#4772](https://github.com/dsj1984/mandrel/issues/4772)) ([#4773](https://github.com/dsj1984/mandrel/issues/4773)) ([16424d5](https://github.com/dsj1984/mandrel/commit/16424d53305549841b6294ceb1d7651baf1f98e7))
35
+
36
+
37
+ ### Changed
38
+
39
+ * **quality:** floor CRAP on methodsAbove20 instead of a fitted max ceiling ([#4779](https://github.com/dsj1984/mandrel/issues/4779)) ([647130b](https://github.com/dsj1984/mandrel/commit/647130b313a85add64978205d4bb300e90e3c9a3)), closes [#4775](https://github.com/dsj1984/mandrel/issues/4775)
40
+
5
41
  ## [2.16.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.15.0...mandrel-v2.16.0) (2026-07-25)
6
42
 
7
43
 
@@ -16,6 +16,13 @@
16
16
  * Builtins only — this module is imported from both the CLI surface
17
17
  * (`lib/cli/`) and the doctor registry which runs before third-party
18
18
  * packages are guaranteed to be present.
19
+ *
20
+ * The two filesystem-touching helpers below take the module's injectable
21
+ * seam as their optional final `fsImpl` parameter, defaulting to the real
22
+ * `node:fs` (`.agents/rules/test-seams.md` rules 1-2);
23
+ * `resolveConsumerPinVersion` forwards that seam to
24
+ * `resolveConsumerPinSpec` rather than re-acquiring `fs` itself (rule 4).
25
+ * Every other export here is pure, so it needs no seam of its own.
19
26
  */
20
27
 
21
28
  import nodeFs from 'node:fs';
@@ -40,10 +40,11 @@ const TAG_LINE_RE = /^(\s*)(@\S+(?:\s+@\S+)*)\s*$/;
40
40
  * Recursively collect `.feature` file paths under `root`.
41
41
  *
42
42
  * @param {string} root
43
- * @param {typeof nodeFs} fsImpl
43
+ * @param {typeof nodeFs} [fsImpl] Filesystem seam; defaults to the real
44
+ * `node:fs` per `.agents/rules/test-seams.md` rule 1.
44
45
  * @returns {string[]}
45
46
  */
46
- function collectFeatureFiles(root, fsImpl) {
47
+ function collectFeatureFiles(root, fsImpl = nodeFs) {
47
48
  /** @type {string[]} */
48
49
  const found = [];
49
50
  /** @type {string[]} */
@@ -71,11 +72,13 @@ function collectFeatureFiles(root, fsImpl) {
71
72
 
72
73
  /**
73
74
  * @param {unknown} ctx
74
- * @param {typeof nodeFs} fsImpl
75
+ * @param {typeof nodeFs} [fsImpl] Filesystem seam; forwarded to
76
+ * {@link collectFeatureFiles} rather than re-acquired there
77
+ * (`.agents/rules/test-seams.md` rule 4).
75
78
  * @returns {string[]} Absolute paths of every `.feature` file under the
76
79
  * canonical feature roots that exist in the consumer tree.
77
80
  */
78
- function resolveFeatureFiles(ctx, fsImpl) {
81
+ function resolveFeatureFiles(ctx, fsImpl = nodeFs) {
79
82
  const projectRoot = ctx?.projectRoot ?? process.cwd();
80
83
  return CANONICAL_FEATURE_ROOTS.flatMap((root) =>
81
84
  collectFeatureFiles(path.join(projectRoot, root), fsImpl),
@@ -119,10 +122,12 @@ export const retireEpicAcTags = {
119
122
  '(their reconciler consumer was deleted in the v2 Epic removal)',
120
123
  /**
121
124
  * @param {{ projectRoot?: string, fs?: typeof nodeFs }} [ctx]
125
+ * @param {typeof nodeFs} [fsImpl] Optional final filesystem seam; defaults
126
+ * to `ctx.fs` when the migration runner threads one, else the real
127
+ * `node:fs` (`.agents/rules/test-seams.md` rule 1).
122
128
  * @returns {boolean}
123
129
  */
124
- detect(ctx) {
125
- const fsImpl = ctx?.fs ?? nodeFs;
130
+ detect(ctx, fsImpl = ctx?.fs ?? nodeFs) {
126
131
  return resolveFeatureFiles(ctx, fsImpl).some((file) => {
127
132
  try {
128
133
  return EPIC_AC_TAG_RE.test(fsImpl.readFileSync(file, 'utf8'));
@@ -133,10 +138,12 @@ export const retireEpicAcTags = {
133
138
  },
134
139
  /**
135
140
  * @param {{ projectRoot?: string, fs?: typeof nodeFs }} [ctx]
141
+ * @param {typeof nodeFs} [fsImpl] Optional final filesystem seam; defaults
142
+ * to `ctx.fs` when the migration runner threads one, else the real
143
+ * `node:fs` (`.agents/rules/test-seams.md` rule 1).
136
144
  * @returns {void}
137
145
  */
138
- apply(ctx) {
139
- const fsImpl = ctx?.fs ?? nodeFs;
146
+ apply(ctx, fsImpl = ctx?.fs ?? nodeFs) {
140
147
  for (const file of resolveFeatureFiles(ctx, fsImpl)) {
141
148
  /** @type {string} */
142
149
  let content;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "2.16.0",
3
+ "version": "2.18.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, skills, rules, and SDLC workflows that govern AI coding assistants.",
5
5
  "files": [
6
6
  ".agents/",
@@ -110,6 +110,10 @@
110
110
  "optional": true
111
111
  }
112
112
  },
113
+ "//": {
114
+ "overrides.js-yaml": "COUPLED to devDependencies.markdownlint-cli2 — do not bump either alone, and do NOT drop this override. It is load-bearing: markdownlint-cli2 0.22.x pulls js-yaml 4.1.1, which carries GHSA-52cp-r559-cp3m (high) and GHSA-h67p-54hq-rp68 (moderate); removing the override was measured to reintroduce both (1 high + 1 moderate), while with it in place `npm audit` is clean. An npm override also wins over a transitive package's own pin, so this tree-wide ^4.2.0 is imposed on every js-yaml consumer regardless of what they declare — and markdownlint-cli2 0.23.x declares an exact js-yaml 5.2.1. A dry-run bump confirmed the trap: markdownlint-cli2 0.23.1 resolves against js-yaml 4.3.0, two majors off what it declares, silently. The only safe move is to raise this override and bump markdownlint-cli2 in ONE reviewed commit (first confirming cosmiconfig, under @commitlint/cli, tolerates the same major). renovate.json excludes markdownlint-cli2 from devDependency auto-merge so that pair cannot drift apart unattended.",
115
+ "dependencies.js-yaml": "States the SAME range as overrides.js-yaml above. The two are deliberate duplicates — npm has no way to reference the direct range from the overrides block — so they MUST move in lockstep; changing one without the other silently splits the direct and transitive resolutions."
116
+ },
113
117
  "overrides": {
114
118
  "js-yaml": "^4.2.0",
115
119
  "markdown-it": "^14.2.0"