mandrel 1.57.0 → 1.59.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 (131) hide show
  1. package/.agents/README.md +89 -87
  2. package/.agents/docs/SDLC.md +11 -7
  3. package/.agents/docs/workflows.md +2 -1
  4. package/.agents/schemas/audit-rules.json +20 -0
  5. package/.agents/scripts/acceptance-eval.js +20 -3
  6. package/.agents/scripts/assert-branch.js +1 -3
  7. package/.agents/scripts/bootstrap.js +1 -1
  8. package/.agents/scripts/check-arch-cycles.js +360 -0
  9. package/.agents/scripts/coverage-capture.js +24 -3
  10. package/.agents/scripts/epic-deliver-preflight.js +5 -3
  11. package/.agents/scripts/epic-deliver-prepare.js +12 -4
  12. package/.agents/scripts/epic-execute-record-wave.js +1 -1
  13. package/.agents/scripts/evidence-gate.js +1 -1
  14. package/.agents/scripts/git-rebase-and-resolve.js +1 -1
  15. package/.agents/scripts/hierarchy-gate.js +34 -14
  16. package/.agents/scripts/lib/baselines/kinds/coverage.js +33 -149
  17. package/.agents/scripts/lib/baselines/kinds/duplication.js +27 -116
  18. package/.agents/scripts/lib/baselines/kinds/kind-factory.js +192 -0
  19. package/.agents/scripts/lib/baselines/kinds/lighthouse.js +34 -133
  20. package/.agents/scripts/lib/baselines/kinds/maintainability.js +31 -124
  21. package/.agents/scripts/lib/baselines/kinds/mutation.js +25 -111
  22. package/.agents/scripts/lib/baselines/maintainability-baseline-io.js +59 -0
  23. package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +37 -0
  24. package/.agents/scripts/lib/baselines/writer.js +1 -1
  25. package/.agents/scripts/lib/close-validation/commands.js +188 -0
  26. package/.agents/scripts/lib/close-validation/gates.js +235 -0
  27. package/.agents/scripts/lib/close-validation/process.js +101 -0
  28. package/.agents/scripts/lib/close-validation/projections/maintainability.js +1 -1
  29. package/.agents/scripts/lib/close-validation/runner.js +325 -0
  30. package/.agents/scripts/lib/close-validation/telemetry.js +70 -0
  31. package/.agents/scripts/lib/config/quality.js +6 -6
  32. package/.agents/scripts/lib/config-resolver.js +2 -5
  33. package/.agents/scripts/lib/coverage-capture.js +147 -4
  34. package/.agents/scripts/lib/cpu-pool.js +14 -0
  35. package/.agents/scripts/lib/crap-utils.js +6 -11
  36. package/.agents/scripts/lib/dynamic-workflow/documentation-report-contract.js +87 -0
  37. package/.agents/scripts/lib/git-utils.js +24 -22
  38. package/.agents/scripts/lib/maintainability-engine.js +1 -1
  39. package/.agents/scripts/lib/maintainability-utils.js +4 -187
  40. package/.agents/scripts/lib/observability/perf-report-readers.js +32 -23
  41. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +80 -6
  42. package/.agents/scripts/lib/orchestration/code-review.js +90 -77
  43. package/.agents/scripts/lib/orchestration/dispatch-pipeline.js +5 -12
  44. package/.agents/scripts/lib/orchestration/epic-deliver-lease-guard.js +14 -14
  45. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/planning-artifacts.js +2 -2
  46. package/.agents/scripts/lib/orchestration/epic-plan-lease-guard.js +184 -49
  47. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/drain.js +1 -1
  48. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/plan-epic.js +26 -2
  49. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +26 -6
  50. package/.agents/scripts/lib/orchestration/epic-runner/phases/build-wave-dag.js +7 -20
  51. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +1 -2
  52. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +0 -6
  53. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +103 -0
  54. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +22 -64
  55. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +38 -76
  56. package/.agents/scripts/lib/orchestration/epic-runner/story-run-progress-writer.js +2 -2
  57. package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +4 -16
  58. package/.agents/scripts/lib/orchestration/file-assumptions.js +4 -3
  59. package/.agents/scripts/lib/orchestration/lease-guard-shared.js +144 -0
  60. package/.agents/scripts/lib/orchestration/lifecycle/emit-story-heartbeat.js +2 -2
  61. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +7 -7
  62. package/.agents/scripts/lib/orchestration/post-merge/phases/notification.js +3 -3
  63. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +7 -7
  64. package/.agents/scripts/lib/orchestration/preflight-cache.js +35 -12
  65. package/.agents/scripts/lib/orchestration/review-providers/codex.js +5 -60
  66. package/.agents/scripts/lib/orchestration/review-providers/native.js +7 -6
  67. package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +105 -0
  68. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +7 -59
  69. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +2 -4
  70. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +241 -0
  71. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +1 -1
  72. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +16 -3
  73. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -4
  74. package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +32 -35
  75. package/.agents/scripts/lib/orchestration/skill-capsule-loader.js +1 -2
  76. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +451 -503
  77. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/pre-merge-attribution.js +8 -2
  78. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/refresh-commit.js +47 -2
  79. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +2 -2
  80. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +358 -54
  81. package/.agents/scripts/lib/orchestration/story-close/phases/close.js +1 -1
  82. package/.agents/scripts/lib/orchestration/story-close/phases/gates.js +3 -2
  83. package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +30 -3
  84. package/.agents/scripts/lib/orchestration/story-close/post-merge-close.js +5 -18
  85. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +3 -3
  86. package/.agents/scripts/lib/orchestration/story-close-recovery.js +33 -16
  87. package/.agents/scripts/lib/orchestration/story-reachability.js +47 -0
  88. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +2 -33
  89. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +42 -64
  90. package/.agents/scripts/lib/orchestration/ticketing/reads.js +9 -0
  91. package/.agents/scripts/lib/orchestration/ticketing/state.js +50 -436
  92. package/.agents/scripts/lib/orchestration/ticketing/transition.js +471 -0
  93. package/.agents/scripts/lib/orchestration/ticketing.js +0 -1
  94. package/.agents/scripts/lib/orchestration/wave-record-notifications.js +1 -1
  95. package/.agents/scripts/lib/orchestration/wave-record-projection.js +1 -7
  96. package/.agents/scripts/lib/project-root.js +17 -0
  97. package/.agents/scripts/lib/story-adjacency.js +76 -0
  98. package/.agents/scripts/lib/story-lifecycle.js +1 -1
  99. package/.agents/scripts/lib/transpile.js +93 -0
  100. package/.agents/scripts/lib/wave-runner/tick.js +4 -153
  101. package/.agents/scripts/lib/workers/crap-worker.js +1 -1
  102. package/.agents/scripts/lib/workers/maintainability-report-worker.js +1 -1
  103. package/.agents/scripts/lib/worktree/lifecycle/creation.js +20 -2
  104. package/.agents/scripts/lib/worktree/lifecycle/force-drain.js +90 -0
  105. package/.agents/scripts/lib/worktree/lifecycle/reap.js +26 -8
  106. package/.agents/scripts/lib/worktree/node-modules-strategy.js +74 -0
  107. package/.agents/scripts/providers/github/tickets.js +110 -6
  108. package/.agents/scripts/run-lint.js +9 -0
  109. package/.agents/scripts/run-tests.js +24 -4
  110. package/.agents/scripts/stories-wave-tick.js +8 -5
  111. package/.agents/scripts/story-init.js +149 -10
  112. package/.agents/scripts/sync-branch-from-base.js +1 -1
  113. package/.agents/skills/stack/qa/lighthouse-baseline/SKILL.md +1 -1
  114. package/.agents/workflows/audit-documentation.md +226 -0
  115. package/.agents/workflows/epic-deliver.md +16 -23
  116. package/.agents/workflows/epic-plan.md +1 -1
  117. package/.agents/workflows/helpers/epic-deliver-story.md +17 -28
  118. package/.agents/workflows/helpers/single-story-deliver.md +2 -1
  119. package/.agents/workflows/onboard.md +4 -3
  120. package/.agents/workflows/story-deliver.md +1 -1
  121. package/README.md +21 -8
  122. package/lib/cli/init.js +336 -0
  123. package/package.json +2 -1
  124. package/.agents/scripts/lib/auto-refresh-baselines.js +0 -308
  125. package/.agents/scripts/lib/close-validation.js +0 -897
  126. package/.agents/scripts/lib/orchestration/cascade-grouping.js +0 -275
  127. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter.js +0 -69
  128. package/.agents/scripts/lib/orchestration/story-close/format-autofix-scoped.js +0 -221
  129. package/.agents/scripts/lib/orchestration/story-close/format-autofix-shared.js +0 -123
  130. package/.agents/scripts/lib/task-utils.js +0 -26
  131. package/.agents/scripts/story-deliver-prepare.js +0 -267
@@ -0,0 +1,336 @@
1
+ // lib/cli/init.js
2
+ /**
3
+ * `mandrel init` subcommand — one-command cold start (Story #3975).
4
+ *
5
+ * Folds the former `create-mandrel` launcher logic into a subcommand of the
6
+ * single published `mandrel` package. `npx mandrel init` (≡ `npm exec --
7
+ * mandrel init`, or `mandrel init` once installed) takes a project from a
8
+ * blank folder to a configured Mandrel environment in one command:
9
+ *
10
+ * 1. **Install-if-absent.** When `./.agents/` is missing in the cwd, install
11
+ * the framework deterministically — `npm install mandrel --ignore-scripts`
12
+ * followed by an explicit sync run against the freshly installed bin
13
+ * (`node ./node_modules/mandrel/bin/mandrel.js sync`, NOT a bare `mandrel`
14
+ * on `PATH` — see `SYNC_BIN` below). The `--ignore-scripts` install
15
+ * skips the package's best-effort `postinstall` sync so the explicit
16
+ * `sync` is the single, deterministic materialization (review rec D.3 —
17
+ * no postinstall-then-init double-sync, and no arbitrary install
18
+ * lifecycle scripts during cold start). When `./.agents/` already exists
19
+ * (the operator ran `npm install mandrel` first), step 1 is skipped and
20
+ * `init` goes straight to the prompt — the one subcommand is idempotent
21
+ * across both the cold-start and post-install entry points.
22
+ *
23
+ * 2. **Two-option prompt.** Ask whether to configure now (option 1 → run
24
+ * `node .agents/scripts/bootstrap.js`, forwarding every passthrough flag
25
+ * unchanged) or stop at "just the files" (option 2 → print a re-run hint
26
+ * and exit 0). `--assume-yes` skips the prompt and configures (the flag is
27
+ * also forwarded to bootstrap for its own non-interactive run). A non-TTY
28
+ * stdin without `--assume-yes` defaults to option 2 (files-only) so the
29
+ * side-effecting GitHub provisioning never runs unattended.
30
+ *
31
+ * ## Cold-start provenance
32
+ *
33
+ * The installed package name is the **hardcoded build-time constant**
34
+ * `PACKAGE_NAME` below — it is NEVER read from argv or the environment. A
35
+ * cold start fetches `mandrel` from npx's temp cache and runs this bin; the
36
+ * package it then installs into the project must be the same `mandrel`, not an
37
+ * attacker-influenced name supplied on the command line. The forwarded
38
+ * passthrough flags reach `bootstrap.js` (the sole bootstrap orchestrator),
39
+ * which owns its own summary + confirm loop and validates its own inputs.
40
+ *
41
+ * ## Injectable seams (used by tests/cli/init.test.js)
42
+ *
43
+ * The plan/decision logic is a pure function (`planInit`) over injected
44
+ * boundaries so the suite is hermetic — no real TTY, npm, or network:
45
+ *
46
+ * - `argv` — subcommand args (after `mandrel init`)
47
+ * - `exists` — `(path) => boolean`; defaults to an `fs.existsSync` probe
48
+ * for `./.agents/` in the cwd
49
+ * - `runStep` — `(cmd, args) => { status }`; runs one install/sync/bootstrap
50
+ * step. Defaults to a `spawnSync` runner with `stdio: inherit`.
51
+ * - `confirm` — `() => '1' | '2'`; reads the operator's numbered choice.
52
+ * Defaults to a synchronous stdin readline prompt.
53
+ * - `stdout` — `(s) => void`; defaults to `process.stdout.write`.
54
+ * - `isTTY` — boolean; defaults to `process.stdin.isTTY`.
55
+ * - `exit` — `(code) => void`; defaults to `process.exit`.
56
+ *
57
+ * Security (security-baseline § 5/6): logs only flag/step descriptions and
58
+ * never reads or echoes tokens, credentials, or env values. The package name
59
+ * is a hardcoded constant rather than interpolated input, and the step runner
60
+ * passes argv as an array (no shell-string concatenation of user input).
61
+ */
62
+
63
+ import { spawnSync } from 'node:child_process';
64
+ import fs from 'node:fs';
65
+ import path from 'node:path';
66
+
67
+ /**
68
+ * Hardcoded build-time package name. NEVER read from argv or env — cold-start
69
+ * provenance requires the install target to be the same package this bin
70
+ * shipped from. See the module header.
71
+ */
72
+ const PACKAGE_NAME = 'mandrel';
73
+
74
+ // The `mandrel sync` and `node .agents/scripts/bootstrap.js` invocations both
75
+ // resolve relative to the consumer's cwd at run time (the materialized
76
+ // `.agents/` lives there), so they are expressed as cwd-relative steps rather
77
+ // than against PROJECT_ROOT (which, under npx, is npx's throwaway cache).
78
+ const BOOTSTRAP_SCRIPT = path.join('.agents', 'scripts', 'bootstrap.js');
79
+
80
+ // The `sync` step is dispatched against the **locally installed** Mandrel bin
81
+ // rather than a bare `mandrel` on `PATH`. A bare `spawnSync('mandrel', …)` only
82
+ // resolves while npx keeps its throwaway `.bin` on `PATH`; reached any other
83
+ // way (a documented `npm install mandrel` then `mandrel init`, or a plain
84
+ // `node bin/mandrel.js init`), the freshly installed binary lives at
85
+ // `./node_modules/mandrel/bin/mandrel.js` — off `PATH` — and a bare spawn dies
86
+ // with ENOENT, leaving `.agents/` un-materialized. Spawning `process.execPath`
87
+ // against the package entrypoint resolves identically under npx, under a
88
+ // post-install invocation, and in tests — and, by going through `node`, also
89
+ // sidesteps the win32 `.cmd`-shim concern entirely (no `shell: true` needed for
90
+ // this step). The path is cwd-relative because the package is installed into
91
+ // the consumer's cwd (the same reason BOOTSTRAP_SCRIPT is cwd-relative).
92
+ const SYNC_BIN = path.join('node_modules', PACKAGE_NAME, 'bin', 'mandrel.js');
93
+
94
+ const PROMPT_TEXT =
95
+ 'Mandrel is installed. What next?\n' +
96
+ ' 1) Configure my environment now (creates the GitHub repo + Projects board)\n' +
97
+ " 2) Just the files — I'll configure later\n" +
98
+ 'Choice [1/2]: ';
99
+
100
+ const FILES_ONLY_HINT = 'Configure any time with: mandrel init\n';
101
+
102
+ // On win32, `npm` resolves to a `.cmd` shim that Node refuses to spawn without
103
+ // a shell after the CVE-2024-27980 hardening; mirror update.js and set
104
+ // `shell: true` only there. Off win32, never shell out — array argv stays
105
+ // injection-proof. (The `sync` step no longer hits a `.cmd` shim at all: it is
106
+ // dispatched as `process.execPath` + `SYNC_BIN`, a plain `node` spawn — but the
107
+ // `npm install` step still needs the shim handling, so `NEEDS_SHELL` stays.)
108
+ const NEEDS_SHELL = process.platform === 'win32';
109
+
110
+ /**
111
+ * Strip the `--assume-yes` flag from a passthrough argv, returning the
112
+ * remaining flags. `--assume-yes` is consumed by `init` to skip the prompt but
113
+ * is ALSO forwarded to bootstrap (see `buildBootstrapArgs`), so this helper
114
+ * exists only to detect its presence, not to drop it from the forward set.
115
+ *
116
+ * @param {string[]} argv
117
+ * @returns {boolean} whether `--assume-yes` is present
118
+ */
119
+ function hasAssumeYes(argv) {
120
+ return argv.includes('--assume-yes');
121
+ }
122
+
123
+ /**
124
+ * Build the forwarded argv for the bootstrap step. Every passthrough flag is
125
+ * forwarded unchanged; `--assume-yes` is appended when chosen but absent so
126
+ * bootstrap runs non-interactively without the operator having typed it.
127
+ *
128
+ * @param {string[]} argv
129
+ * @param {boolean} assumeYes
130
+ * @returns {string[]}
131
+ */
132
+ function buildBootstrapArgs(argv, assumeYes) {
133
+ if (assumeYes && !argv.includes('--assume-yes')) {
134
+ return [...argv, '--assume-yes'];
135
+ }
136
+ return [...argv];
137
+ }
138
+
139
+ /**
140
+ * Decide and execute the cold-start plan over injected boundaries.
141
+ *
142
+ * This is the testable core: it consults `exists` to decide whether to run the
143
+ * install + sync steps, resolves the prompt outcome from `isTTY` / `--assume-yes`
144
+ * / `confirm`, and either runs the bootstrap step or prints the files-only hint.
145
+ * Every effectful boundary is injected so the suite never touches a real TTY,
146
+ * npm, or the network.
147
+ *
148
+ * @param {{
149
+ * argv?: string[],
150
+ * exists?: (relPath: string) => boolean,
151
+ * runStep?: (cmd: string, args: string[]) => { status: number | null },
152
+ * confirm?: () => '1' | '2',
153
+ * stdout?: (s: string) => void,
154
+ * isTTY?: boolean,
155
+ * }} [opts]
156
+ * @returns {{
157
+ * installed: boolean,
158
+ * ranBootstrap: boolean,
159
+ * steps: Array<{ cmd: string, args: string[] }>,
160
+ * exitCode: number,
161
+ * }}
162
+ */
163
+ export function planInit({
164
+ argv = [],
165
+ exists,
166
+ runStep,
167
+ confirm,
168
+ stdout = (s) => process.stdout.write(s),
169
+ isTTY,
170
+ } = {}) {
171
+ const steps = [];
172
+
173
+ /**
174
+ * Run one step through the injected runner and record it. A non-zero status
175
+ * short-circuits the plan with that exit code (the runner inherits stdio, so
176
+ * the failing tool's own output already reached the terminal).
177
+ *
178
+ * @param {string} cmd
179
+ * @param {string[]} args
180
+ * @returns {number} the step's exit code (0 on success)
181
+ */
182
+ const step = (cmd, args) => {
183
+ steps.push({ cmd, args });
184
+ const result = runStep(cmd, args);
185
+ return result?.status ?? 1;
186
+ };
187
+
188
+ // --- Step 1: install-if-absent ------------------------------------------
189
+ // When `./.agents/` is missing, materialize the framework deterministically:
190
+ // `npm install <pkg> --ignore-scripts` then explicit `mandrel sync`. When it
191
+ // is present, skip straight to the prompt (idempotent post-install path).
192
+ const agentsPresent = exists('.agents');
193
+ if (!agentsPresent) {
194
+ const installStatus = step('npm', [
195
+ 'install',
196
+ PACKAGE_NAME,
197
+ '--ignore-scripts',
198
+ ]);
199
+ if (installStatus !== 0) {
200
+ return {
201
+ installed: false,
202
+ ranBootstrap: false,
203
+ steps,
204
+ exitCode: installStatus,
205
+ };
206
+ }
207
+
208
+ const syncStatus = step(process.execPath, [SYNC_BIN, 'sync']);
209
+ if (syncStatus !== 0) {
210
+ return {
211
+ installed: true,
212
+ ranBootstrap: false,
213
+ steps,
214
+ exitCode: syncStatus,
215
+ };
216
+ }
217
+ }
218
+
219
+ const installed = !agentsPresent;
220
+
221
+ // --- Step 2: configure-or-files prompt ----------------------------------
222
+ const assumeYes = hasAssumeYes(argv);
223
+
224
+ // Decide the outcome: configure (run bootstrap) vs. files-only.
225
+ // - `--assume-yes` → configure, prompt skipped entirely.
226
+ // - non-TTY without `--assume-yes` → files-only (never provision unattended).
227
+ // - TTY → consult the confirm seam for the numbered choice.
228
+ let choice;
229
+ if (assumeYes) {
230
+ choice = '1';
231
+ } else if (!isTTY) {
232
+ choice = '2';
233
+ } else {
234
+ stdout(PROMPT_TEXT);
235
+ choice = confirm();
236
+ }
237
+
238
+ if (choice === '1') {
239
+ const bootstrapArgs = buildBootstrapArgs(argv, assumeYes);
240
+ const bootstrapStatus = step(process.execPath, [
241
+ BOOTSTRAP_SCRIPT,
242
+ ...bootstrapArgs,
243
+ ]);
244
+ return {
245
+ installed,
246
+ ranBootstrap: true,
247
+ steps,
248
+ exitCode: bootstrapStatus,
249
+ };
250
+ }
251
+
252
+ // Option 2 (files-only): print the re-run hint and exit cleanly.
253
+ stdout(FILES_ONLY_HINT);
254
+ return { installed, ranBootstrap: false, steps, exitCode: 0 };
255
+ }
256
+
257
+ /**
258
+ * Default `exists` seam — probe for `./.agents/` in the consumer's cwd.
259
+ *
260
+ * @param {string} relPath
261
+ * @returns {boolean}
262
+ */
263
+ function defaultExists(relPath) {
264
+ return fs.existsSync(path.resolve(process.cwd(), relPath));
265
+ }
266
+
267
+ /**
268
+ * Default step runner — spawn the tool synchronously, inheriting stdio so its
269
+ * output streams to the terminal. Sets `shell: true` only on win32 so the
270
+ * `npm.cmd` shim resolves under CVE-2024-27980 hardening. The `sync` and
271
+ * bootstrap steps spawn `process.execPath` (a plain `node`), which needs no
272
+ * shell on any platform.
273
+ *
274
+ * @param {string} cmd
275
+ * @param {string[]} args
276
+ * @returns {{ status: number | null }}
277
+ */
278
+ function defaultRunStep(cmd, args) {
279
+ return spawnSync(cmd, args, {
280
+ stdio: 'inherit',
281
+ env: process.env,
282
+ shell: NEEDS_SHELL,
283
+ });
284
+ }
285
+
286
+ /**
287
+ * Default `confirm` seam — synchronous numbered-choice prompt. Reads one line
288
+ * from stdin and normalizes it to `'1'` or `'2'`; any input other than `'2'`
289
+ * (including bare Enter) defaults to `'1'` (configure), matching the
290
+ * `Choice [1/2]:` convention where the first option is the default.
291
+ *
292
+ * @returns {'1' | '2'}
293
+ */
294
+ function defaultConfirm() {
295
+ let answer = '';
296
+ try {
297
+ const buf = fs.readFileSync(0, 'utf8');
298
+ answer = buf.split('\n', 1)[0].trim();
299
+ } catch {
300
+ // No readable line (e.g. stdin closed) → fall through to the default.
301
+ answer = '';
302
+ }
303
+ return answer === '2' ? '2' : '1';
304
+ }
305
+
306
+ /**
307
+ * Default export consumed by `bin/mandrel.js`.
308
+ *
309
+ * @param {string[]} [argv] - Subcommand arguments (after `mandrel init`).
310
+ * @returns {void}
311
+ */
312
+ export default function run(argv = []) {
313
+ if (argv.includes('--help') || argv.includes('-h')) {
314
+ process.stdout.write(
315
+ 'Usage: mandrel init [bootstrap flags]\n\n' +
316
+ ' One-command cold start: install Mandrel (if absent), then prompt to\n' +
317
+ ' configure now or stop at the files.\n\n' +
318
+ ' --assume-yes Skip the prompt and configure non-interactively\n' +
319
+ ' (forwarded to bootstrap.js). All other flags are\n' +
320
+ ' forwarded to bootstrap.js unchanged.\n',
321
+ );
322
+ return;
323
+ }
324
+
325
+ const result = planInit({
326
+ argv,
327
+ exists: defaultExists,
328
+ runStep: defaultRunStep,
329
+ confirm: defaultConfirm,
330
+ isTTY: Boolean(process.stdin.isTTY),
331
+ });
332
+
333
+ if (result.exitCode !== 0) {
334
+ process.exit(result.exitCode);
335
+ }
336
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "1.57.0",
3
+ "version": "1.59.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, personas, skills, and SDLC workflows that govern AI coding assistants.",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -36,6 +36,7 @@
36
36
  "format:check": "biome ci .",
37
37
  "maintainability:check": "node .agents/scripts/check-baselines.js --gate maintainability",
38
38
  "maintainability:update": "node .agents/scripts/update-maintainability-baseline.js",
39
+ "check:arch": "node .agents/scripts/check-arch-cycles.js",
39
40
  "crap:check": "node .agents/scripts/check-baselines.js --gate crap",
40
41
  "crap:update": "node .agents/scripts/update-crap-baseline.js",
41
42
  "duplication:check": "node .agents/scripts/check-baselines.js --gate duplication",
@@ -1,308 +0,0 @@
1
- /**
2
- * auto-refresh-baselines.js — pure delta-cap evaluator for the bounded
3
- * baseline auto-refresh at story-close (Story #1398, Epic #1386).
4
- *
5
- * Story #1891 (Epic #1786) note: this evaluator is purely functional and
6
- * has never written a baseline JSON file directly — every regenerated row
7
- * funnels through the per-kind refresh entry points
8
- * (`update-crap-baseline.js`, `update-maintainability-baseline.js`,
9
- * `lib/coverage-baseline.js`'s `writeBaseline`), which now write through
10
- * the shared `lib/baselines/writer.js`. The migration leaves this
11
- * evaluator unchanged structurally; it remains the single seam for the
12
- * delta-cap decision.
13
- *
14
- * After the close-validation chain has passed (`runPreMergeGatesWithAttribution`
15
- * already auto-refreshed any attributable drift), `story-close` regenerates
16
- * the baseline rows scoped to the Story diff and asks this evaluator whether
17
- * the regenerated rows are within bounded delta caps. If yes, `story-close`
18
- * amends the regenerated rows into the close commit (no separate
19
- * `baseline-refresh:` commit). If no, `story-close` refuses to amend, leaves
20
- * the close commit untouched, and appends a `baseline-refresh-regression`
21
- * friction signal naming the offending file(s)/method(s).
22
- *
23
- * The evaluator is a pure function — no I/O, no spawn, no provider calls.
24
- * Inputs are fixtures the caller has already loaded; outputs are plain
25
- * objects ready for the friction-signal renderer.
26
- *
27
- * Contract:
28
- *
29
- * evaluateAutoRefresh({ scoredRows, baseline, caps }) →
30
- * {
31
- * canAutoRefresh: boolean,
32
- * miOverCap: Array<{ path, baseline, scored, delta }>,
33
- * crapOverCap: Array<{ file, method, startLine, baseline, scored, delta }>,
34
- * refusalReasons: string[],
35
- * }
36
- *
37
- * - `scoredRows` carries the *just-regenerated* rows, partitioned by kind:
38
- * { mi: Array<{ path, mi }>, crap: Array<{ file, method, startLine, crap }> }
39
- * Either kind may be absent or empty — the evaluator treats a missing
40
- * kind as "no rows of that kind to evaluate".
41
- *
42
- * - `baseline` carries the *previously committed* rows, in the same shape:
43
- * { mi: Array<{ path, mi }>, crap: Array<{ file, method, startLine, crap }> }
44
- * A scored row whose path/method has no matching baseline row is treated
45
- * as "new" and evaluated against the cap with `baseline = null` — its
46
- * delta is the scored value vs an absent prior, which by convention
47
- * never breaches a cap (the row didn't exist before, there is no drop /
48
- * jump to bound). New rows therefore never block auto-refresh.
49
- *
50
- * - `caps = { miDropCap: number, crapJumpCap: number }` carries the
51
- * bounded delta thresholds. The defaults (`miDropCap: 1.5`,
52
- * `crapJumpCap: 5`) live in `.agents/docs/agentrc-reference.json` under
53
- * `delivery.quality.autoRefresh` (Story #1413). Callers always
54
- * pass an explicit caps object; the evaluator does not default-fill.
55
- *
56
- * Cap semantics:
57
- *
58
- * - MI is "higher is better". A *drop* (baseline.mi − scored.mi) greater
59
- * than `miDropCap` breaches the cap. Improvements (scored.mi ≥ baseline.mi
60
- * or any negative drop) never breach.
61
- *
62
- * - CRAP is "lower is better". A *jump* (scored.crap − baseline.crap)
63
- * greater than `crapJumpCap` breaches the cap. Improvements (scored.crap
64
- * ≤ baseline.crap or any negative jump) never breach.
65
- *
66
- * - Equality at the cap (delta === cap) is *under* the cap — the cap is
67
- * the maximum allowed delta, not the strict maximum. This matches the
68
- * Tech Spec's "at or below" wording and the AC1 phrasing ("every scored
69
- * row delta is at or below the configured caps").
70
- *
71
- * - Missing baseline rows (path/method new in the scored set) are recorded
72
- * with `baseline: null` and a `delta` of 0 — they never push
73
- * `canAutoRefresh` to `false`. The evaluator does not surface them in
74
- * `miOverCap` / `crapOverCap`. The friction renderer therefore never
75
- * names a file that was newly introduced by the Story.
76
- *
77
- * The renderer is pure so callers can unit-test the cap math against fixed
78
- * inputs without spawning git or reading the filesystem. Story-close wires
79
- * it into the post-validation amend path; tests for the wiring live in
80
- * `tests/story-close-auto-refresh.test.js` (Story #1415).
81
- */
82
-
83
- /**
84
- * Numeric guard — accepts finite numbers only. Strings, NaN, Infinity, null,
85
- * undefined all fail. The evaluator runs against scored rows produced by the
86
- * MI / CRAP scanners (which always emit numeric scores) and baseline rows
87
- * loaded from the on-disk JSON (which JSON-parses numeric fields), so a
88
- * non-finite value here signals upstream corruption — we exclude the row
89
- * conservatively rather than coercing.
90
- */
91
- function isFiniteNumber(value) {
92
- return typeof value === 'number' && Number.isFinite(value);
93
- }
94
-
95
- /**
96
- * Index `baseline.mi` rows by `path` for O(1) lookup. Bad rows (missing
97
- * `path`, non-string `path`, non-finite `mi`) are skipped — their absence
98
- * causes the matching scored row to be treated as "new", which never blocks
99
- * auto-refresh.
100
- */
101
- function indexMiBaseline(rows) {
102
- const byPath = new Map();
103
- if (!Array.isArray(rows)) return byPath;
104
- for (const row of rows) {
105
- if (!row || typeof row.path !== 'string' || row.path.length === 0) continue;
106
- if (!isFiniteNumber(row.mi)) continue;
107
- byPath.set(row.path, row);
108
- }
109
- return byPath;
110
- }
111
-
112
- /**
113
- * Index `baseline.crap` rows by `${file}::${method}` for O(1) lookup.
114
- * `startLine` is *not* part of the key — the scored row may have shifted
115
- * lines vs the baseline (legitimate refactor), and we want the closest match
116
- * by method name. When the same method appears multiple times in the same
117
- * file (e.g. nested helpers), we pick the closest startLine at lookup time.
118
- *
119
- * Bad rows (missing `file`/`method`, non-finite `crap`) are skipped — their
120
- * absence causes the matching scored row to be treated as "new".
121
- */
122
- function indexCrapBaseline(rows) {
123
- const byMethod = new Map();
124
- if (!Array.isArray(rows)) return byMethod;
125
- for (const row of rows) {
126
- if (!row || typeof row.file !== 'string' || row.file.length === 0) {
127
- continue;
128
- }
129
- if (typeof row.method !== 'string' || row.method.length === 0) continue;
130
- if (!isFiniteNumber(row.crap)) continue;
131
- const key = `${row.file}::${row.method}`;
132
- if (!byMethod.has(key)) byMethod.set(key, []);
133
- byMethod.get(key).push(row);
134
- }
135
- return byMethod;
136
- }
137
-
138
- /**
139
- * Pick the closest baseline candidate by `startLine` distance. When the
140
- * scored row's `startLine` is missing or all candidates have missing line
141
- * info, returns the first candidate — matches `baseline-attribution-wiring`'s
142
- * `diffCrapBaselines` resolution policy.
143
- */
144
- function pickClosestBaseline(candidates, scoredStartLine) {
145
- if (!Array.isArray(candidates) || candidates.length === 0) return null;
146
- if (candidates.length === 1) return candidates[0];
147
- const target = isFiniteNumber(scoredStartLine) ? scoredStartLine : 0;
148
- let best = candidates[0];
149
- let bestDist = Math.abs((best.startLine ?? 0) - target);
150
- for (let i = 1; i < candidates.length; i += 1) {
151
- const c = candidates[i];
152
- const dist = Math.abs((c?.startLine ?? 0) - target);
153
- if (dist < bestDist) {
154
- bestDist = dist;
155
- best = c;
156
- }
157
- }
158
- return best;
159
- }
160
-
161
- /**
162
- * Evaluate every MI scored row against the MI cap. Returns the over-cap
163
- * subset; rows under the cap (or new) are simply omitted from the result.
164
- *
165
- * MI is higher-is-better, so drift = baseline.mi − scored.mi. A positive
166
- * drift is a regression; a drift greater than `miDropCap` breaches the cap.
167
- */
168
- function evaluateMiRows({ scoredRows, baselineIndex, miDropCap }) {
169
- const overCap = [];
170
- if (!Array.isArray(scoredRows)) return overCap;
171
- for (const row of scoredRows) {
172
- if (!row || typeof row.path !== 'string' || row.path.length === 0) {
173
- continue;
174
- }
175
- if (!isFiniteNumber(row.mi)) continue;
176
- const baselineRow = baselineIndex.get(row.path);
177
- if (!baselineRow) continue; // new path — never breaches
178
- const drop = baselineRow.mi - row.mi;
179
- if (drop > miDropCap) {
180
- overCap.push({
181
- path: row.path,
182
- baseline: baselineRow.mi,
183
- scored: row.mi,
184
- delta: drop,
185
- });
186
- }
187
- }
188
- return overCap;
189
- }
190
-
191
- /**
192
- * Evaluate every CRAP scored row against the CRAP cap. Returns the over-cap
193
- * subset; rows under the cap (or new) are simply omitted from the result.
194
- *
195
- * CRAP is lower-is-better, so jump = scored.crap − baseline.crap. A positive
196
- * jump is a regression; a jump greater than `crapJumpCap` breaches the cap.
197
- */
198
- function evaluateCrapRows({ scoredRows, baselineIndex, crapJumpCap }) {
199
- const overCap = [];
200
- if (!Array.isArray(scoredRows)) return overCap;
201
- for (const row of scoredRows) {
202
- if (!row || typeof row.file !== 'string' || row.file.length === 0) {
203
- continue;
204
- }
205
- if (typeof row.method !== 'string' || row.method.length === 0) continue;
206
- if (!isFiniteNumber(row.crap)) continue;
207
- const candidates = baselineIndex.get(`${row.file}::${row.method}`);
208
- const baselineRow = pickClosestBaseline(candidates, row.startLine);
209
- if (!baselineRow) continue; // new method — never breaches
210
- const jump = row.crap - baselineRow.crap;
211
- if (jump > crapJumpCap) {
212
- overCap.push({
213
- file: row.file,
214
- method: row.method,
215
- startLine: row.startLine,
216
- baseline: baselineRow.crap,
217
- scored: row.crap,
218
- delta: jump,
219
- });
220
- }
221
- }
222
- return overCap;
223
- }
224
-
225
- /**
226
- * Build the human-readable refusal reasons array. Stable formatting so the
227
- * friction-signal renderer (and unit tests) can pin the strings exactly.
228
- *
229
- * Each reason names the kind, the file/path/method, and the absolute delta
230
- * vs the cap. Numbers are formatted to 3 decimal places to match the
231
- * baseline JSON's float precision without trailing-zero noise.
232
- */
233
- function buildRefusalReasons({ miOverCap, crapOverCap, caps }) {
234
- const reasons = [];
235
- for (const r of miOverCap) {
236
- reasons.push(
237
- `MI drop ${r.delta.toFixed(3)} > cap ${caps.miDropCap} on ${r.path} (baseline ${r.baseline.toFixed(3)} → scored ${r.scored.toFixed(3)})`,
238
- );
239
- }
240
- for (const r of crapOverCap) {
241
- reasons.push(
242
- `CRAP jump ${r.delta.toFixed(3)} > cap ${caps.crapJumpCap} on ${r.file}::${r.method} (baseline ${r.baseline.toFixed(3)} → scored ${r.scored.toFixed(3)})`,
243
- );
244
- }
245
- return reasons;
246
- }
247
-
248
- /**
249
- * Pure delta-cap evaluator. Decides whether the regenerated rows can be
250
- * silently amended into the close commit (under-cap) or whether the close
251
- * must refuse the amend and surface a `baseline-refresh-regression` friction
252
- * signal (over-cap).
253
- *
254
- * @param {object} input
255
- * @param {{
256
- * mi?: Array<{ path: string, mi: number }>,
257
- * crap?: Array<{ file: string, method: string, startLine?: number, crap: number }>,
258
- * }} input.scoredRows Just-regenerated rows for the Story diff.
259
- * @param {{
260
- * mi?: Array<{ path: string, mi: number }>,
261
- * crap?: Array<{ file: string, method: string, startLine?: number, crap: number }>,
262
- * }} input.baseline Previously committed rows.
263
- * @param {{ miDropCap: number, crapJumpCap: number }} input.caps
264
- * Bounded delta caps (defaults: miDropCap=1.5, crapJumpCap=5 — see
265
- * `.agents/docs/agentrc-reference.json` under `delivery.quality.autoRefresh`).
266
- * @returns {{
267
- * canAutoRefresh: boolean,
268
- * miOverCap: Array<{ path: string, baseline: number, scored: number, delta: number }>,
269
- * crapOverCap: Array<{ file: string, method: string, startLine?: number, baseline: number, scored: number, delta: number }>,
270
- * refusalReasons: string[],
271
- * }}
272
- */
273
- export function evaluateAutoRefresh({
274
- scoredRows = {},
275
- baseline = {},
276
- caps,
277
- } = {}) {
278
- if (
279
- !caps ||
280
- !isFiniteNumber(caps.miDropCap) ||
281
- !isFiniteNumber(caps.crapJumpCap)
282
- ) {
283
- throw new TypeError(
284
- 'evaluateAutoRefresh: caps.{miDropCap,crapJumpCap} must be finite numbers',
285
- );
286
- }
287
-
288
- const miBaselineIdx = indexMiBaseline(baseline?.mi);
289
- const crapBaselineIdx = indexCrapBaseline(baseline?.crap);
290
-
291
- const miOverCap = evaluateMiRows({
292
- scoredRows: scoredRows?.mi,
293
- baselineIndex: miBaselineIdx,
294
- miDropCap: caps.miDropCap,
295
- });
296
- const crapOverCap = evaluateCrapRows({
297
- scoredRows: scoredRows?.crap,
298
- baselineIndex: crapBaselineIdx,
299
- crapJumpCap: caps.crapJumpCap,
300
- });
301
-
302
- const canAutoRefresh = miOverCap.length === 0 && crapOverCap.length === 0;
303
- const refusalReasons = canAutoRefresh
304
- ? []
305
- : buildRefusalReasons({ miOverCap, crapOverCap, caps });
306
-
307
- return { canAutoRefresh, miOverCap, crapOverCap, refusalReasons };
308
- }