mandrel 2.24.0 → 2.26.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 (189) hide show
  1. package/.agents/agents/acceptance-critic.md +19 -21
  2. package/.agents/agents/auditor.md +16 -19
  3. package/.agents/agents/story-worker.md +41 -54
  4. package/.agents/audit-checklists/architecture.md +5 -1
  5. package/.agents/audit-checklists/baselines.md +21 -0
  6. package/.agents/audit-checklists/quality.md +3 -0
  7. package/.agents/docs/agentrc-reference.json +2 -1
  8. package/.agents/docs/configuration.md +2 -1
  9. package/.agents/docs/quality-gates.md +80 -18
  10. package/.agents/docs/workflows.md +4 -2
  11. package/.agents/instructions.md +1 -1
  12. package/.agents/rules/ci-remediation.md +68 -3
  13. package/.agents/schemas/agentrc.schema.json +6 -1
  14. package/.agents/schemas/audit-rules.json +15 -0
  15. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  16. package/.agents/schemas/baselines/baseline-envelope.schema.json +8 -0
  17. package/.agents/schemas/baselines/crap.schema.json +13 -1
  18. package/.agents/schemas/crap-report.schema.json +37 -0
  19. package/.agents/schemas/model-attribution.schema.json +4 -0
  20. package/.agents/scripts/acceptance-eval.js +124 -15
  21. package/.agents/scripts/audit-baselines.js +136 -0
  22. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  23. package/.agents/scripts/audit-to-stories.js +33 -3
  24. package/.agents/scripts/check-arch-cycles.js +12 -93
  25. package/.agents/scripts/check-baseline-drift.js +16 -3
  26. package/.agents/scripts/check-baselines.js +19 -3
  27. package/.agents/scripts/check-context-budget.js +95 -14
  28. package/.agents/scripts/check-cyclomatic.js +214 -0
  29. package/.agents/scripts/check-schema-references.js +392 -0
  30. package/.agents/scripts/check-test-temp-hygiene.js +121 -1
  31. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  32. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  33. package/.agents/scripts/deliver-light.js +3 -4
  34. package/.agents/scripts/deliver-recover.js +13 -0
  35. package/.agents/scripts/diagnose-friction.js +85 -19
  36. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  37. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  38. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  39. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  40. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  41. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  42. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  43. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  44. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  45. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  46. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  47. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  48. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  49. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  50. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  51. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  52. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  53. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  54. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  55. package/.agents/scripts/lib/baseline-loader.js +0 -0
  56. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  57. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  58. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  59. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  60. package/.agents/scripts/lib/baselines/kinds/crap.js +499 -57
  61. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  62. package/.agents/scripts/lib/baselines/reader.js +10 -0
  63. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  64. package/.agents/scripts/lib/baselines/scope.js +39 -90
  65. package/.agents/scripts/lib/baselines/writer.js +16 -7
  66. package/.agents/scripts/lib/changed-files.js +8 -1
  67. package/.agents/scripts/lib/cli-args.js +115 -1
  68. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  69. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  70. package/.agents/scripts/lib/crap-engine.js +124 -27
  71. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  72. package/.agents/scripts/lib/crap-utils.js +86 -13
  73. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  74. package/.agents/scripts/lib/env-loader.js +46 -16
  75. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  76. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  77. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  78. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  79. package/.agents/scripts/lib/findings/severity.js +80 -2
  80. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  81. package/.agents/scripts/lib/git-utils.js +136 -80
  82. package/.agents/scripts/lib/import-graph.js +156 -0
  83. package/.agents/scripts/lib/label-constants.js +17 -0
  84. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  85. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  86. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  87. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  88. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  89. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  90. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  91. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  92. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  93. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  94. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  95. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  96. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  97. package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
  98. package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
  99. package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
  100. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  101. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  102. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +216 -109
  103. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +61 -13
  104. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  105. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  106. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  107. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  108. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  109. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  110. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  111. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  112. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  113. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +125 -37
  114. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  115. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  116. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  117. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  118. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +267 -125
  119. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  120. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  121. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  122. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  123. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  124. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  125. package/.agents/scripts/lib/story-adjacency.js +3 -3
  126. package/.agents/scripts/lib/story-plan.js +137 -42
  127. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  128. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  129. package/.agents/scripts/lib/test-tiers.js +11 -2
  130. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  131. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  132. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  133. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  134. package/.agents/scripts/lib/wave-runner/live-probe.js +29 -13
  135. package/.agents/scripts/lib/wave-runner/ready-set.js +254 -43
  136. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +10 -13
  137. package/.agents/scripts/lib/workers/crap-worker.js +8 -13
  138. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  139. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  140. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  141. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  142. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  143. package/.agents/scripts/lib/worktree-manager.js +14 -0
  144. package/.agents/scripts/plan-run-epilogue.js +17 -5
  145. package/.agents/scripts/pr-watch-with-update.js +568 -213
  146. package/.agents/scripts/providers/github/tickets.js +33 -10
  147. package/.agents/scripts/provision-git-hooks.js +85 -0
  148. package/.agents/scripts/quality-preview.js +137 -28
  149. package/.agents/scripts/resolve-stories.js +4 -1
  150. package/.agents/scripts/run-coverage.js +86 -35
  151. package/.agents/scripts/run-lint.js +20 -0
  152. package/.agents/scripts/run-tests.js +36 -36
  153. package/.agents/scripts/single-story-close.js +34 -2
  154. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  155. package/.agents/scripts/single-story-init.js +7 -0
  156. package/.agents/scripts/stories-wave-tick.js +308 -47
  157. package/.agents/scripts/story-plan.js +65 -9
  158. package/.agents/scripts/update-coverage-baseline.js +34 -4
  159. package/.agents/scripts/update-crap-baseline.js +42 -4
  160. package/.agents/scripts/update-duplication-baseline.js +209 -83
  161. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  162. package/.agents/scripts/validate-docs-freshness.js +1 -0
  163. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  164. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  165. package/.agents/skills/skills.index.json +2 -2
  166. package/.agents/templates/single-story-body.md +16 -8
  167. package/.agents/workflows/audit-architecture.md +69 -13
  168. package/.agents/workflows/audit-baselines.md +289 -0
  169. package/.agents/workflows/audit-navigability.md +5 -4
  170. package/.agents/workflows/audit-quality.md +26 -0
  171. package/.agents/workflows/audit-to-stories.md +30 -4
  172. package/.agents/workflows/deliver.md +92 -97
  173. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  174. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  175. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  176. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  177. package/.agents/workflows/helpers/deliver-reference.md +95 -4
  178. package/.agents/workflows/helpers/deliver-story-reference.md +174 -52
  179. package/.agents/workflows/helpers/deliver-story.md +104 -119
  180. package/.agents/workflows/helpers/plan-reference.md +49 -5
  181. package/.agents/workflows/memory-consolidate.md +116 -0
  182. package/.agents/workflows/plan.md +62 -75
  183. package/README.md +13 -6
  184. package/docs/CHANGELOG.md +93 -0
  185. package/lib/cli/update.js +14 -11
  186. package/lib/cli/version-check.js +9 -1
  187. package/package.json +9 -4
  188. package/.agents/schemas/friction-event.schema.json +0 -56
  189. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -37,16 +37,19 @@ import fs from 'node:fs';
37
37
  import path from 'node:path';
38
38
  import process from 'node:process';
39
39
  import { runAsCli } from './lib/cli-utils.js';
40
+ import {
41
+ buildGraph,
42
+ collectJsFiles,
43
+ DEFAULT_ROOTS,
44
+ parseRelativeImports,
45
+ } from './lib/import-graph.js';
40
46
 
41
- /**
42
- * Default scan roots making up the project's distributed surface — the
43
- * directories published to npm via `package.json` `files[]`. Resolving
44
- * them into one graph (relativized against the repo root) means a cycle
45
- * crossing two roots is visible to `findCycles`.
46
- *
47
- * @type {string[]}
48
- */
49
- export const DEFAULT_ROOTS = [path.join('.agents', 'scripts'), 'bin', 'lib'];
47
+ // The import-graph builder itself lives in `lib/import-graph.js` (Story
48
+ // #4902) so `audit-baselines.js` can rank hotspots by import in-degree
49
+ // against the same graph this ratchet detects cycles in. Re-exported here
50
+ // because this module's named exports are its unit-test surface and its
51
+ // documented contract; the behaviour is unchanged by the move.
52
+ export { buildGraph, collectJsFiles, DEFAULT_ROOTS, parseRelativeImports };
50
53
 
51
54
  /**
52
55
  * Parse argv for `--baseline <path>`, `--root <path>`, and `--json`.
@@ -80,90 +83,6 @@ export function parseArgv(argv = []) {
80
83
  return { baselinePath, rootPath, json };
81
84
  }
82
85
 
83
- /**
84
- * Recursively collect `.js` files under `rootDir`, skipping
85
- * `node_modules`. Returns absolute paths, sorted for determinism.
86
- *
87
- * @param {string} rootDir
88
- * @returns {string[]}
89
- */
90
- export function collectJsFiles(rootDir) {
91
- const out = [];
92
- const walk = (dir) => {
93
- let entries;
94
- try {
95
- entries = fs.readdirSync(dir, { withFileTypes: true });
96
- } catch {
97
- return;
98
- }
99
- for (const entry of entries) {
100
- if (entry.name === 'node_modules') continue;
101
- const full = path.join(dir, entry.name);
102
- if (entry.isDirectory()) {
103
- walk(full);
104
- } else if (entry.isFile() && entry.name.endsWith('.js')) {
105
- out.push(full);
106
- }
107
- }
108
- };
109
- walk(rootDir);
110
- return out.sort();
111
- }
112
-
113
- const IMPORT_RE = /from\s+['"](\.\.?\/[^'"]+\.js)['"]/g;
114
-
115
- /**
116
- * Pure helper: extract relative static-import specifiers from source text.
117
- *
118
- * @param {string} source
119
- * @returns {string[]}
120
- */
121
- export function parseRelativeImports(source) {
122
- const specs = [];
123
- for (const m of source.matchAll(IMPORT_RE)) {
124
- specs.push(m[1]);
125
- }
126
- return specs;
127
- }
128
-
129
- /**
130
- * Build a directed import graph over the given files. Node identity is the
131
- * file path relative to `rootDir`, posix-separated, so the graph (and any
132
- * cycles found in it) serializes identically across platforms. Edges that
133
- * resolve outside the scanned file set are dropped.
134
- *
135
- * @param {string[]} files absolute paths
136
- * @param {string} rootDir
137
- * @param {{ readFile?: (p: string) => string }} [opts]
138
- * @returns {Map<string, string[]>}
139
- */
140
- export function buildGraph(files, rootDir, { readFile } = {}) {
141
- const read = readFile ?? ((p) => fs.readFileSync(p, 'utf-8'));
142
- const toId = (abs) => path.relative(rootDir, abs).split(path.sep).join('/');
143
- const idSet = new Set(files.map(toId));
144
- const graph = new Map();
145
- for (const file of files) {
146
- const id = toId(file);
147
- let source;
148
- try {
149
- source = read(file);
150
- } catch {
151
- graph.set(id, []);
152
- continue;
153
- }
154
- const edges = [];
155
- for (const spec of parseRelativeImports(source)) {
156
- const target = path
157
- .relative(rootDir, path.resolve(path.dirname(file), spec))
158
- .split(path.sep)
159
- .join('/');
160
- if (idSet.has(target) && target !== id) edges.push(target);
161
- }
162
- graph.set(id, [...new Set(edges)].sort());
163
- }
164
- return graph;
165
- }
166
-
167
86
  /**
168
87
  * Pure helper: rotate a cycle (array of module ids, no repeated terminal
169
88
  * element) so it starts at its lexicographically-smallest member. The same
@@ -118,21 +118,34 @@ export async function runCheckBaselineDrift({
118
118
  return { exitCode: run.ok ? 0 : 1, output };
119
119
  }
120
120
 
121
+ /**
122
+ * Run the drift check and *return* its exit code rather than calling
123
+ * `process.exit()` — this CLI prints one row per drifted baseline entry
124
+ * full-scope, so its report is exactly the kind of payload that outgrows the
125
+ * 64 KiB pipe buffer under a `| tee`. `process.exit()` terminates before a
126
+ * queued async pipe write drains, silently truncating it (Story #4783, the
127
+ * same defect `check-baselines.js` carried). Handing the code back lets
128
+ * `runAsCli`'s `propagateExitCode` path settle it through
129
+ * `settleCli`/`flushStdio` instead. The 0/1/2 contract documented at the top
130
+ * of this file is unchanged — only *when* the process leaves is.
131
+ *
132
+ * @returns {Promise<number>} 0 no drift, 1 drift detected, 2 could not run.
133
+ */
121
134
  async function main() {
122
135
  let result;
123
136
  try {
124
137
  result = await runCheckBaselineDrift({ argv: process.argv.slice(2) });
125
138
  } catch (err) {
126
139
  process.stdout.write(`${err?.message ?? String(err)}\n`);
127
- process.exit(2);
128
- return;
140
+ return 2;
129
141
  }
130
142
  process.stdout.write(`${result.output}\n`);
131
- process.exit(result.exitCode);
143
+ return result.exitCode;
132
144
  }
133
145
 
134
146
  runAsCli(import.meta.url, main, {
135
147
  source: 'check-baseline-drift',
136
148
  usage: HELP_TEXT,
137
149
  exitCode: 2,
150
+ propagateExitCode: true,
138
151
  });
@@ -65,6 +65,22 @@ export {
65
65
  selectEnabledGates,
66
66
  };
67
67
 
68
+ /**
69
+ * Run the dispatcher and *return* its exit code rather than calling
70
+ * `process.exit()`.
71
+ *
72
+ * The return is load-bearing (Story #4783's defect, reintroduced here):
73
+ * `process.exit()` terminates before a queued async pipe write drains, so a
74
+ * full `--gate coverage` report — a quarter of a megabyte of JSON — arrived
75
+ * truncated at the 64 KiB pipe boundary under the `| tee` in CI's coverage
76
+ * step, while still exiting 0. Handing the code back to `runAsCli`'s
77
+ * `propagateExitCode` path settles it through `settleCli`/`flushStdio`
78
+ * instead, which assigns `process.exitCode` and lets Node terminate once
79
+ * stdout has drained. The 0/1/2/3/4 contract in `lib/baselines/exit-codes.js`
80
+ * is unchanged — only *when* the process leaves is.
81
+ *
82
+ * @returns {Promise<number>} An exit code from the `EXIT_*` contract.
83
+ */
68
84
  async function main() {
69
85
  let result;
70
86
  try {
@@ -74,14 +90,14 @@ async function main() {
74
90
  process.stdout.write(
75
91
  `${JSON.stringify({ schemaVersion: '1', error: message }, null, 2)}\n`,
76
92
  );
77
- process.exit(EXIT_CONFIG);
78
- return;
93
+ return EXIT_CONFIG;
79
94
  }
80
95
  process.stdout.write(`${result.output}\n`);
81
- process.exit(result.exitCode);
96
+ return result.exitCode;
82
97
  }
83
98
 
84
99
  runAsCli(import.meta.url, main, {
85
100
  source: 'check-baselines',
86
101
  usage: HELP_TEXT,
102
+ propagateExitCode: true,
87
103
  });
@@ -47,9 +47,18 @@
47
47
  * Ratchet semantics (mirroring the sibling ratchets):
48
48
  * - A gated tier grows beyond `baseline.tiers.<tier>.totalBytes +
49
49
  * baseline.toleranceBytes` → exit 1, naming the tier and its delta.
50
- * - A gated tier shrinks below its baseline total → printed as a `-`
51
- * (removal) note, warning the baseline can be refreshed downward.
52
- * Shrink-only exits 0.
50
+ * - A gated tier shrinks below its baseline total → exit 1 (Story #4872).
51
+ * A ratchet that only tightens in one direction lets every measured
52
+ * improvement evaporate: the recorded total keeps promising headroom the
53
+ * tree no longer spends, so the next growth is absorbed by stale slack
54
+ * instead of being reported. Shrinkage is therefore **actionable** —
55
+ * refresh the baseline down and the gain is locked in. Unlike growth this
56
+ * is deliberately **zero-tolerance**: `toleranceBytes` exists to keep a
57
+ * trivial addition from churning the file, and applying it downward would
58
+ * silently discard every sub-tolerance gain.
59
+ * - A recorded row naming a path the measured tier no longer contains →
60
+ * exit 1. The row describes a file that has been deleted or de-listed, so
61
+ * the bytes it contributes to the recorded total are fiction.
53
62
  * - Within tolerance / clean → exit 0.
54
63
  * - Baseline file absent → warn + exit 0 (no-op; nothing to ratchet against).
55
64
  *
@@ -303,16 +312,45 @@ export function buildBaseline(tierMap, toleranceBytes) {
303
312
  };
304
313
  }
305
314
 
315
+ /**
316
+ * Collect the recorded rows of one gated tier that name a path the measured
317
+ * tier no longer contains (Story #4872). A deleted file drops out of the
318
+ * resolved tier, and so does one that has been de-listed from the read set —
319
+ * either way the row's bytes are counted into a recorded total that no live
320
+ * file backs, so the row is drift and not a detail.
321
+ *
322
+ * @param {string} tier
323
+ * @param {Array<{ path: string, bytes: number }>} files live tier measurement
324
+ * @param {{ files?: Array<{ path: string, bytes?: number }> }} baseTier recorded tier
325
+ * @returns {Array<{ tier: string, path: string, bytes: number|null }>}
326
+ */
327
+ function absentRows(tier, files, baseTier) {
328
+ const live = new Set(files.map((f) => f.path));
329
+ const out = [];
330
+ for (const row of baseTier?.files ?? []) {
331
+ if (typeof row?.path !== 'string' || live.has(row.path)) continue;
332
+ out.push({
333
+ tier,
334
+ path: row.path,
335
+ bytes: Number.isFinite(row.bytes) ? row.bytes : null,
336
+ });
337
+ }
338
+ return out;
339
+ }
340
+
306
341
  /**
307
342
  * Pure diff: compare the current tier map against the committed baseline. A
308
343
  * gated tier with no current files is skipped; a tier absent from the baseline
309
- * is skipped. `grown` entries fail the gate; `shrunk` entries are informational.
344
+ * is skipped. `grown`, `shrunk` and `absent` entries all fail the gate see
345
+ * the ratchet semantics in the module header for why shrinkage is actionable
346
+ * rather than informational (Story #4872).
310
347
  *
311
348
  * @param {{ tiers: Record<string, Array<{ path: string, bytes: number }>> }} tierMap
312
349
  * @param {{ toleranceBytes?: number, tiers?: Record<string, { totalBytes: number }> }} baseline
313
350
  * @returns {{
314
351
  * grown: Array<{ tier: string, current: number, baseline: number, tolerance: number, delta: number }>,
315
- * shrunk: Array<{ tier: string, current: number, baseline: number }>,
352
+ * shrunk: Array<{ tier: string, current: number, baseline: number, delta: number }>,
353
+ * absent: Array<{ tier: string, path: string, bytes: number|null }>,
316
354
  * skipped: string[],
317
355
  * }}
318
356
  */
@@ -322,6 +360,7 @@ export function diffBudget(tierMap, baseline) {
322
360
  : 0;
323
361
  const grown = [];
324
362
  const shrunk = [];
363
+ const absent = [];
325
364
  const skipped = [];
326
365
  for (const tier of GATED_TIERS) {
327
366
  const files = tierMap.tiers[tier] ?? [];
@@ -345,16 +384,42 @@ export function diffBudget(tierMap, baseline) {
345
384
  delta: current - baselineBytes,
346
385
  });
347
386
  } else if (current < baselineBytes) {
348
- shrunk.push({ tier, current, baseline: baselineBytes });
387
+ // Deliberately zero-tolerance: `tolerance` guards against churn from a
388
+ // trivial *addition*; mirroring it downward would discard every gain
389
+ // smaller than the tolerance, which is the leak this branch closes.
390
+ shrunk.push({
391
+ tier,
392
+ current,
393
+ baseline: baselineBytes,
394
+ delta: baselineBytes - current,
395
+ });
349
396
  }
397
+ absent.push(...absentRows(tier, files, baseTier));
350
398
  }
351
- return { grown, shrunk, skipped };
399
+ return { grown, shrunk, absent, skipped };
400
+ }
401
+
402
+ /**
403
+ * Count the drift entries that fail the gate. Every direction is actionable
404
+ * (Story #4872), so this is the one place the failure set is defined and both
405
+ * the summary tag and the exit code read it.
406
+ *
407
+ * @param {ReturnType<typeof diffBudget>} diff
408
+ * @returns {number}
409
+ */
410
+ export function budgetFailureCount(diff) {
411
+ return (
412
+ (diff?.grown?.length ?? 0) +
413
+ (diff?.shrunk?.length ?? 0) +
414
+ (diff?.absent?.length ?? 0)
415
+ );
352
416
  }
353
417
 
354
418
  /**
355
419
  * Render the human-readable diff. `+` lines are tiers that grew beyond
356
- * tolerance (gate fail); `-` lines are tiers that shrank (refreshable
357
- * baseline). A one-line summary always follows.
420
+ * tolerance; `-` lines are tiers that shrank below their recorded total or
421
+ * rows naming a path the tree no longer carries. All three fail the gate. A
422
+ * one-line summary always follows.
358
423
  *
359
424
  * @param {ReturnType<typeof diffBudget>} diff
360
425
  * @returns {string}
@@ -368,12 +433,17 @@ export function renderDiff(diff) {
368
433
  }
369
434
  for (const s of diff.shrunk) {
370
435
  lines.push(
371
- `- ${s.tier}: ${s.current} bytes below baseline ${s.baseline} — refresh baselines/context-budget.json`,
436
+ `- ${s.tier}: ${s.current} bytes is under the recorded ${s.baseline} (delta -${s.delta}) the ratchet is holding slack the tree no longer spends; refresh baselines/context-budget.json`,
437
+ );
438
+ }
439
+ for (const a of diff.absent ?? []) {
440
+ lines.push(
441
+ `- ${a.tier}: recorded row ${a.path} names a path the measured tier no longer contains — refresh baselines/context-budget.json`,
372
442
  );
373
443
  }
374
- const tag = diff.grown.length > 0 ? '(gate fail)' : '(ok)';
444
+ const tag = budgetFailureCount(diff) > 0 ? '(gate fail)' : '(ok)';
375
445
  lines.push(
376
- `[context-budget] grown=${diff.grown.length} shrunk=${diff.shrunk.length} skipped=${diff.skipped.length} ${tag}`,
446
+ `[context-budget] grown=${diff.grown.length} shrunk=${diff.shrunk.length} absent=${diff.absent?.length ?? 0} skipped=${diff.skipped.length} ${tag}`,
377
447
  );
378
448
  return lines.join('\n');
379
449
  }
@@ -454,7 +524,7 @@ export async function runCli({
454
524
  if (!baseline) {
455
525
  if (json) {
456
526
  stdout.write(
457
- `${JSON.stringify({ kind: 'context-budget-report', baselinePath: resolvedBaselinePath, tiers: tierMap.tiers, grown: [], shrunk: [], skipped: GATED_TIERS, exitCode: 0, noBaseline: true }, null, 2)}\n`,
527
+ `${JSON.stringify({ kind: 'context-budget-report', baselinePath: resolvedBaselinePath, tiers: tierMap.tiers, grown: [], shrunk: [], absent: [], skipped: GATED_TIERS, exitCode: 0, noBaseline: true }, null, 2)}\n`,
458
528
  );
459
529
  } else {
460
530
  stderr.write(
@@ -472,7 +542,7 @@ export async function runCli({
472
542
  const bootDrift = agentBootDrift(tierMap, baseline, ceiling);
473
543
  const permissiveDrift = bootDrift.filter((d) => d.direction === 'permissive');
474
544
  const exitCode =
475
- diff.grown.length > 0 ||
545
+ budgetFailureCount(diff) > 0 ||
476
546
  bootOverflow.length > 0 ||
477
547
  permissiveDrift.length > 0
478
548
  ? 1
@@ -490,6 +560,7 @@ export async function runCli({
490
560
  ),
491
561
  grown: diff.grown,
492
562
  shrunk: diff.shrunk,
563
+ absent: diff.absent,
493
564
  skipped: diff.skipped,
494
565
  agentBootCeilingBytes: ceiling,
495
566
  agentBootOverflow: bootOverflow,
@@ -527,6 +598,16 @@ export async function runCli({
527
598
  `[context-budget] ❌ a documentation tier grew beyond tolerance — refresh the budget consciously with \`node .agents/scripts/check-context-budget.js --update\` once the growth is intentional\n`,
528
599
  );
529
600
  }
601
+ if (diff.shrunk.length > 0) {
602
+ stderr.write(
603
+ `[context-budget] ❌ a documentation tier came in under its recorded total — the ratchet is holding slack the tree no longer spends, so the next growth would be absorbed silently. Lock the gain in with \`node .agents/scripts/check-context-budget.js --update\`\n`,
604
+ );
605
+ }
606
+ if (diff.absent.length > 0) {
607
+ stderr.write(
608
+ `[context-budget] ❌ a recorded row names a path the measured tier no longer contains — its bytes inflate the recorded total against nothing. Refresh with \`node .agents/scripts/check-context-budget.js --update\`\n`,
609
+ );
610
+ }
530
611
  }
531
612
  }
532
613
 
@@ -0,0 +1,214 @@
1
+ /**
2
+ * CLI: ratchet on cyclomatic complexity against
3
+ * `delivery.quality.codingGuardrails.cyclomaticMustFix` (Story #4923).
4
+ *
5
+ * The must-fix ceiling was documented as blocking (`code-quality-guardrails.md`
6
+ * promises "the close-validation chain refuses the merge") while being read by
7
+ * nothing. This is the consumer that makes the promise true. It joins the
8
+ * standalone-ratchet family — `check-arch-cycles.js`, `check-dead-exports.js`,
9
+ * `check-context-budget.js` — which the CI `baselines` job runs as a required
10
+ * check, and it follows their contract exactly:
11
+ *
12
+ * - Reads the committed breach baseline at `baselines/cyclomatic.json`
13
+ * (override with `--baseline <path>`).
14
+ * - Walks the maintainability gate's `targetDirs` / `ignoreGlobs` and scores
15
+ * every function with the in-repo escomplex kernel — no coverage artifact
16
+ * required, so the verdict is available on a cold checkout.
17
+ * - Exit 0 when clean, improved, or shrinking; exit 1 when a file gains an
18
+ * over-ceiling function or its worst function gets worse.
19
+ *
20
+ * `--update` rewrites the baseline from the current tree. That is the
21
+ * sanctioned motion after a deliberate refactor lands, and the only way the
22
+ * recorded breach count is allowed to rise.
23
+ */
24
+
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import process from 'node:process';
28
+ import { runAsCli } from './lib/cli-utils.js';
29
+ import { getQuality, resolveConfig } from './lib/config-resolver.js';
30
+ import {
31
+ buildCyclomaticEnvelope,
32
+ DEFAULT_CYCLOMATIC_BASELINE,
33
+ diffCyclomaticRows,
34
+ renderCyclomaticDiff,
35
+ resolveCyclomaticPolicy,
36
+ scanCyclomatic,
37
+ } from './lib/cyclomatic-ceiling.js';
38
+
39
+ /**
40
+ * Parse `--baseline <path>`, `--json`, and `--update`.
41
+ *
42
+ * @param {string[]} argv
43
+ * @returns {{ baselinePath: string | null, json: boolean, update: boolean }}
44
+ */
45
+ function parseArgv(argv = []) {
46
+ let baselinePath = null;
47
+ let json = false;
48
+ let update = false;
49
+ for (let i = 0; i < argv.length; i += 1) {
50
+ const a = argv[i];
51
+ if (a === '--baseline') {
52
+ const next = argv[i + 1];
53
+ if (next && !next.startsWith('--')) {
54
+ baselinePath = next;
55
+ i += 1;
56
+ }
57
+ } else if (a === '--json') {
58
+ json = true;
59
+ } else if (a === '--update') {
60
+ update = true;
61
+ }
62
+ }
63
+ return { baselinePath, json, update };
64
+ }
65
+
66
+ /**
67
+ * Read a breach baseline envelope from disk. Returns `null` when the file is
68
+ * missing or unparseable — the caller treats that as an empty baseline, which
69
+ * makes the very first run report every existing breach as `added` rather than
70
+ * silently passing.
71
+ *
72
+ * @param {string} baselinePath
73
+ * @returns {{ ceiling?: number, rows?: Array<object> } | null}
74
+ */
75
+ function loadCyclomaticBaseline(baselinePath) {
76
+ try {
77
+ if (!fs.existsSync(baselinePath)) return null;
78
+ const parsed = JSON.parse(fs.readFileSync(baselinePath, 'utf-8'));
79
+ return parsed && typeof parsed === 'object' ? parsed : null;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Top-level CLI entry. Exported so tests can drive the whole pipeline through
87
+ * the injected seams below without spawning a process.
88
+ *
89
+ * @param {{
90
+ * argv?: string[],
91
+ * cwd?: string,
92
+ * stdout?: { write: (s: string) => void },
93
+ * stderr?: { write: (s: string) => void },
94
+ * resolveConfigImpl?: typeof resolveConfig,
95
+ * scanImpl?: typeof scanCyclomatic,
96
+ * loadBaselineImpl?: typeof loadCyclomaticBaseline,
97
+ * writeFileImpl?: (p: string, data: string) => void,
98
+ * }} [opts]
99
+ * @returns {Promise<number>} 0 = clean / improved; 1 = ratchet breached
100
+ */
101
+ export async function runCli({
102
+ argv = process.argv.slice(2),
103
+ cwd = process.cwd(),
104
+ stdout = process.stdout,
105
+ stderr = process.stderr,
106
+ resolveConfigImpl = resolveConfig,
107
+ scanImpl = scanCyclomatic,
108
+ loadBaselineImpl = loadCyclomaticBaseline,
109
+ writeFileImpl = (p, data) => fs.writeFileSync(p, data),
110
+ } = {}) {
111
+ const { baselinePath, json, update } = parseArgv(argv);
112
+ const quality = getQuality(resolveConfigImpl({ cwd }));
113
+ const policy = resolveCyclomaticPolicy(quality);
114
+ const resolvedBaselinePath = path.resolve(
115
+ cwd,
116
+ baselinePath ?? DEFAULT_CYCLOMATIC_BASELINE,
117
+ );
118
+
119
+ const scan = scanImpl({
120
+ targetDirs: policy.targetDirs,
121
+ ignoreGlobs: policy.ignoreGlobs,
122
+ ceiling: policy.mustFix,
123
+ cwd,
124
+ });
125
+
126
+ if (update) {
127
+ const envelope = buildCyclomaticEnvelope({
128
+ rows: scan.rows,
129
+ ceiling: policy.mustFix,
130
+ });
131
+ writeFileImpl(
132
+ resolvedBaselinePath,
133
+ `${JSON.stringify(envelope, null, 2)}\n`,
134
+ );
135
+ stdout.write(
136
+ `[cyclomatic] wrote ${scan.rows.length} breach row(s) at ceiling c=${policy.mustFix} to ${resolvedBaselinePath}\n`,
137
+ );
138
+ return 0;
139
+ }
140
+
141
+ const baseline = loadBaselineImpl(resolvedBaselinePath);
142
+ const baselineRows = Array.isArray(baseline?.rows) ? baseline.rows : [];
143
+ const diff = diffCyclomaticRows(baselineRows, scan.rows);
144
+ const exitCode = diff.added.length + diff.worsened.length > 0 ? 1 : 0;
145
+
146
+ if (json) {
147
+ stdout.write(
148
+ `${JSON.stringify(
149
+ {
150
+ kind: 'cyclomatic-report',
151
+ ceiling: policy.mustFix,
152
+ flag: policy.flag,
153
+ baselinePath: resolvedBaselinePath,
154
+ baselineCeiling: baseline?.ceiling ?? null,
155
+ scannedFiles: scan.scannedFiles,
156
+ parseErrors: scan.parseErrors,
157
+ baselineRows,
158
+ currentRows: scan.rows,
159
+ ...diff,
160
+ exitCode,
161
+ },
162
+ null,
163
+ 2,
164
+ )}\n`,
165
+ );
166
+ return exitCode;
167
+ }
168
+
169
+ if (!baseline) {
170
+ stderr.write(
171
+ `[cyclomatic] ⚠ baseline not found at ${resolvedBaselinePath} — treating as empty\n`,
172
+ );
173
+ } else if (
174
+ typeof baseline.ceiling === 'number' &&
175
+ baseline.ceiling !== policy.mustFix
176
+ ) {
177
+ stderr.write(
178
+ `[cyclomatic] ⚠ baseline was recorded at ceiling c=${baseline.ceiling} but the configured cyclomaticMustFix is c=${policy.mustFix} — re-run with --update\n`,
179
+ );
180
+ }
181
+ stdout.write('\n--- cyclomatic preview ---\n');
182
+ stdout.write(`${renderCyclomaticDiff(diff, policy.mustFix)}\n`);
183
+ return exitCode;
184
+ }
185
+
186
+ async function main() {
187
+ return runCli();
188
+ }
189
+
190
+ runAsCli(import.meta.url, main, {
191
+ source: 'cyclomatic',
192
+ propagateExitCode: true,
193
+ errorPrefix: '[cyclomatic] ❌ Fatal error',
194
+ usage: {
195
+ invocation:
196
+ 'node .agents/scripts/check-cyclomatic.js [--baseline <path>] [--update] [--json]',
197
+ summary:
198
+ 'Ratchet on cyclomatic complexity: fail when a file gains a function above `delivery.quality.codingGuardrails.cyclomaticMustFix`, or when its worst function gets worse than the recorded baseline.',
199
+ flags: [
200
+ [
201
+ '--baseline <path>',
202
+ 'Baseline file (default: baselines/cyclomatic.json).',
203
+ ],
204
+ [
205
+ '--update',
206
+ 'Rewrite the baseline from the current tree (the post-refactor motion).',
207
+ ],
208
+ ['--json', 'Emit the comparison envelope as JSON.'],
209
+ ],
210
+ notes: [
211
+ 'Exit codes:\n 0 clean, improved, or shrinking\n 1 a new or worsened over-ceiling function',
212
+ ],
213
+ },
214
+ });