mandrel 2.36.0 → 2.37.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 (88) hide show
  1. package/.agents/docs/configuration.md +25 -0
  2. package/.agents/rules/testing-standards.md +56 -0
  3. package/.agents/scripts/boot-sweep.js +7 -1
  4. package/.agents/scripts/check-context-budget.js +195 -103
  5. package/.agents/scripts/check-cyclomatic.js +112 -42
  6. package/.agents/scripts/check-generated-validator.js +202 -0
  7. package/.agents/scripts/check-lifecycle-lint.js +2 -68
  8. package/.agents/scripts/check-schema-references.js +2 -28
  9. package/.agents/scripts/drain-pending-cleanup.js +18 -1
  10. package/.agents/scripts/lib/baselines/components.js +32 -2
  11. package/.agents/scripts/lib/baselines/env-overrides.js +1 -1
  12. package/.agents/scripts/lib/bootstrap/branch-protection.js +1 -1
  13. package/.agents/scripts/lib/bootstrap/install-ledger.js +1 -1
  14. package/.agents/scripts/lib/bootstrap/prompt.js +1 -1
  15. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +2 -2
  16. package/.agents/scripts/lib/checks/loop-health.js +1 -1
  17. package/.agents/scripts/lib/cli-args.js +0 -2
  18. package/.agents/scripts/lib/close-validation/gates.js +1 -1
  19. package/.agents/scripts/lib/close-validation/process.js +1 -1
  20. package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
  21. package/.agents/scripts/lib/config-settings-schema.js +46 -6
  22. package/.agents/scripts/lib/coverage-baseline.js +2 -2
  23. package/.agents/scripts/lib/cpu-pool.js +90 -10
  24. package/.agents/scripts/lib/crap-utils.js +6 -2
  25. package/.agents/scripts/lib/cyclomatic-ceiling.js +28 -6
  26. package/.agents/scripts/lib/cyclomatic-scope.js +144 -0
  27. package/.agents/scripts/lib/dependency-version.js +86 -0
  28. package/.agents/scripts/lib/duplicate-search.js +1 -1
  29. package/.agents/scripts/lib/errors/index.js +1 -1
  30. package/.agents/scripts/lib/feedback-loop/graduator-core.js +2 -2
  31. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +1 -1
  32. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +1 -1
  33. package/.agents/scripts/lib/generated/agentrc-validator.js +16 -0
  34. package/.agents/scripts/lib/install-cmd-parser.js +1 -1
  35. package/.agents/scripts/lib/knip-entry-sync.js +1 -57
  36. package/.agents/scripts/lib/maintainability-utils.js +108 -10
  37. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  38. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +70 -9
  39. package/.agents/scripts/lib/orchestration/pr-watch.js +1 -30
  40. package/.agents/scripts/lib/orchestration/reassert-status-column.js +2 -2
  41. package/.agents/scripts/lib/orchestration/review-providers/codex.js +3 -3
  42. package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +1 -1
  43. package/.agents/scripts/lib/orchestration/review-providers/native.js +9 -2
  44. package/.agents/scripts/lib/orchestration/review-providers/review-depth.js +2 -2
  45. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -3
  46. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +52 -1
  47. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +2 -5
  48. package/.agents/scripts/lib/orchestration/story-follow-ups.js +1 -1
  49. package/.agents/scripts/lib/orchestration/ticket-lease.js +34 -9
  50. package/.agents/scripts/lib/orchestration/ticketing/reads.js +3 -1
  51. package/.agents/scripts/lib/preflight-runner.js +3 -3
  52. package/.agents/scripts/lib/qa/qa-session.js +1 -1
  53. package/.agents/scripts/lib/runtime-deps/manifest.js +1 -7
  54. package/.agents/scripts/lib/runtime-deps/scan-imports.js +3 -58
  55. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +284 -25
  56. package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
  57. package/.agents/scripts/lib/source-text/strip-js-comments.js +115 -0
  58. package/.agents/scripts/lib/test-isolate/runner.js +3 -3
  59. package/.agents/scripts/lib/test-runner-contract.js +14 -6
  60. package/.agents/scripts/lib/test-tiers.js +135 -18
  61. package/.agents/scripts/lib/transpile.js +16 -3
  62. package/.agents/scripts/lib/worktree/lifecycle/pending-cleanup.js +169 -31
  63. package/.agents/scripts/lib/worktree/node-modules-strategy.js +2 -5
  64. package/.agents/scripts/pr-watch-with-update.js +305 -137
  65. package/.agents/scripts/providers/github/tickets.js +109 -16
  66. package/.agents/scripts/quality-preview.js +162 -70
  67. package/.agents/scripts/run-test-profile.js +8 -5
  68. package/.agents/scripts/run-tests.js +79 -14
  69. package/.agents/scripts/single-story-close.js +0 -2
  70. package/.agents/scripts/single-story-init.js +5 -1
  71. package/docs/CHANGELOG.md +32 -0
  72. package/lib/cli/migrate.js +100 -94
  73. package/lib/migrations/helpers/retire-agentrc-key.js +200 -0
  74. package/lib/migrations/index.js +32 -33
  75. package/lib/migrations/steps/2.1.0-retire-mi-drop-knobs.js +21 -80
  76. package/lib/migrations/steps/2.1.0-retire-verify-concurrency-cap.js +15 -87
  77. package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +13 -71
  78. package/lib/migrations/steps/2.20.0-retire-codebase-snapshot.js +13 -101
  79. package/lib/migrations/steps/2.32.0-retire-lint-baseline-command.js +13 -100
  80. package/package.json +7 -3
  81. package/.agents/scripts/lib/audit-suite/frontmatter-lint.js +0 -32
  82. package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +0 -37
  83. package/.agents/scripts/lib/cli/parse-numeric.js +0 -60
  84. package/.agents/scripts/lib/close-validation/telemetry.js +0 -79
  85. package/.agents/scripts/lib/orchestration/label-transitions.js +0 -44
  86. package/.agents/scripts/lib/orchestration/parked-follow-ons.js +0 -147
  87. package/.agents/scripts/lib/orchestration/phase-runner.js +0 -88
  88. package/.agents/scripts/lib/orchestration/recut.js +0 -56
@@ -292,7 +292,9 @@ export function computeExitCode(merged, miExit, crapExit) {
292
292
  *
293
293
  * The last header used to hardcode `c=8`, which quietly lied to any consumer
294
294
  * that had tuned `codingGuardrails.cyclomaticFlag`. It now names the value the
295
- * count was actually taken against, read off the merge result.
295
+ * count was actually taken against, read off the merge result — through the
296
+ * same `normalizeFlag` the merge itself uses, rather than the second, hand-
297
+ * inlined copy of that coercion this function used to carry.
296
298
  *
297
299
  * Pure — accepts pre-computed merge rows and returns a multi-line string. The
298
300
  * table renders even on a clean diff so operators see the "no drift" signal.
@@ -300,33 +302,40 @@ export function computeExitCode(merged, miExit, crapExit) {
300
302
  * @param {{ rows: Array<{ file: string, miDrop: number, worstCrapDelta: number, newOverCeilingMethods: number }>, totals: { miRegressions: number, crapViolations: number }, cyclomaticFlag?: number }} merged
301
303
  * @returns {string}
302
304
  */
305
+ /**
306
+ * The table's body: one row per regressed file, or the single placeholder
307
+ * line that keeps the "no drift" signal visible on a clean diff.
308
+ *
309
+ * Split out of `renderTable` so that function keeps a flat shape — the
310
+ * empty-vs-populated branch and the row loop together carried it above the
311
+ * per-method CRAP contract the pre-push preview enforces.
312
+ *
313
+ * @param {Array<{ file: string, miDrop: number, worstCrapDelta: number, newOverCeilingMethods: number }>} rows
314
+ * @returns {string[]}
315
+ */
316
+ function tableBodyLines(rows) {
317
+ if (rows.length === 0) return ['| _(no per-file regressions)_ | — | — | — |'];
318
+ return rows.map(
319
+ (row) =>
320
+ `| ${row.file} | -${row.miDrop.toFixed(2)} | +${row.worstCrapDelta.toFixed(2)} | ${row.newOverCeilingMethods} |`,
321
+ );
322
+ }
323
+
303
324
  export function renderTable(merged) {
304
- const flag = Number.isFinite(Number(merged?.cyclomaticFlag))
305
- ? Number(merged.cyclomaticFlag)
306
- : DEFAULT_CYCLOMATIC_FLAG;
325
+ const flag = normalizeFlag(merged?.cyclomaticFlag);
307
326
  const header = [
308
327
  'file',
309
328
  'MI delta',
310
329
  'worst CRAP delta',
311
330
  `new-method count over c=${flag}`,
312
331
  ];
313
- const lines = [];
314
- lines.push(`| ${header.join(' | ')} |`);
315
- lines.push(`| ${header.map(() => '---').join(' | ')} |`);
316
- if (merged.rows.length === 0) {
317
- lines.push('| _(no per-file regressions)_ | — | — | — |');
318
- } else {
319
- for (const row of merged.rows) {
320
- lines.push(
321
- `| ${row.file} | -${row.miDrop.toFixed(2)} | +${row.worstCrapDelta.toFixed(2)} | ${row.newOverCeilingMethods} |`,
322
- );
323
- }
324
- }
325
- lines.push('');
326
- lines.push(
332
+ return [
333
+ `| ${header.join(' | ')} |`,
334
+ `| ${header.map(() => '---').join(' | ')} |`,
335
+ ...tableBodyLines(merged.rows),
336
+ '',
327
337
  `Totals: MI regressions=${merged.totals.miRegressions} · CRAP violations=${merged.totals.crapViolations}`,
328
- );
329
- return lines.join('\n');
338
+ ].join('\n');
330
339
  }
331
340
 
332
341
  /**
@@ -354,6 +363,96 @@ function resolveCyclomaticFlag({ cwd, stderr }) {
354
363
  }
355
364
  }
356
365
 
366
+ /**
367
+ * Invoke one preview runner, degrading a thrown failure into the same
368
+ * `{ exitCode: 1, envelope: null }` shape a real gate failure produces.
369
+ *
370
+ * Both runners degraded identically before, in two hand-copied `catch`
371
+ * arms; folding them into one helper removes the copy and keeps `runCli` a
372
+ * pipeline rather than a pair of inlined error handlers. The emitted message
373
+ * is byte-identical to the arm it replaces — `label` supplies the `MI` /
374
+ * `CRAP` prefix.
375
+ *
376
+ * @param {(args: object) => Promise<{exitCode: number, envelope: object|null}>} runner
377
+ * @param {{cwd: string, staged: boolean, changedSinceRef: string|null}} args
378
+ * @param {'MI'|'CRAP'} label
379
+ * @param {{ write: (s: string) => void }} stderr
380
+ * @returns {Promise<{exitCode: number, envelope: object|null}>}
381
+ */
382
+ function runGateSafely(runner, args, label, stderr) {
383
+ return runner(args).catch((err) => {
384
+ stderr.write(
385
+ `[quality:preview] ${label} runner failed: ${err?.message ?? err}\n`,
386
+ );
387
+ return { exitCode: 1, envelope: null };
388
+ });
389
+ }
390
+
391
+ /**
392
+ * Write the run's report — the `--json` envelope, or the human-readable
393
+ * table plus any gate diagnostics and the non-zero-exit summary.
394
+ *
395
+ * Split out of `runCli` (Story #5109): the rendering half carried five of
396
+ * that function's decision points, which put it over the per-method CRAP
397
+ * contract the pre-push preview enforces. Output bytes are unchanged in both
398
+ * modes. The exit code stays with the caller — this function only reports.
399
+ *
400
+ * @param {{
401
+ * json: boolean,
402
+ * staged: boolean,
403
+ * ref: string|null,
404
+ * miResult: {exitCode: number, envelope: object|null},
405
+ * crapResult: {exitCode: number, envelope: object|null},
406
+ * merged: ReturnType<typeof mergeEnvelopes>,
407
+ * stdout: { write: (s: string) => void },
408
+ * stderr: { write: (s: string) => void },
409
+ * }} args
410
+ * @returns {void}
411
+ */
412
+ function emitReport({
413
+ json,
414
+ staged,
415
+ ref,
416
+ miResult,
417
+ crapResult,
418
+ merged,
419
+ stdout,
420
+ stderr,
421
+ }) {
422
+ const miExit = miResult.exitCode;
423
+ const crapExit = crapResult.exitCode;
424
+ if (json) {
425
+ stdout.write(
426
+ `${JSON.stringify(
427
+ {
428
+ ref: staged ? null : ref,
429
+ staged,
430
+ mi: { exit: miExit, envelope: miResult.envelope },
431
+ crap: { exit: crapExit, envelope: crapResult.envelope },
432
+ merged,
433
+ },
434
+ null,
435
+ 2,
436
+ )}\n`,
437
+ );
438
+ return;
439
+ }
440
+ stdout.write('\n--- quality:preview ---\n');
441
+ stdout.write(
442
+ staged
443
+ ? 'scope=staged (git diff --cached)\n\n'
444
+ : `scope=diff ref=${ref}\n\n`,
445
+ );
446
+ stdout.write(`${renderTable(merged)}\n`);
447
+ const diagnostics = renderDiagnostics([miResult, crapResult]);
448
+ if (diagnostics) stdout.write(`\n${diagnostics}\n`);
449
+ if (miExit !== 0 || crapExit !== 0) {
450
+ stderr.write(
451
+ `\n[quality:preview] gate exits: mi=${miExit} crap=${crapExit}\n`,
452
+ );
453
+ }
454
+ }
455
+
357
456
  /**
358
457
  * Top-level CLI entry: invoke both per-kind preview runners, merge, render,
359
458
  * and exit with the right code. Exposed as `runCli` so tests can drive the
@@ -381,62 +480,55 @@ export async function runCli({
381
480
  const staged = parseStagedFlag(argv);
382
481
  const ref = staged ? null : (parseChangedSinceArg(argv) ?? 'HEAD');
383
482
 
384
- const [miResult, crapResult] = await Promise.all([
385
- runMi({ cwd, staged, changedSinceRef: ref }).catch((err) => {
386
- stderr.write(
387
- `[quality:preview] MI runner failed: ${err?.message ?? err}\n`,
388
- );
389
- return { exitCode: 1, envelope: null };
390
- }),
391
- runCrap({ cwd, staged, changedSinceRef: ref }).catch((err) => {
392
- stderr.write(
393
- `[quality:preview] CRAP runner failed: ${err?.message ?? err}\n`,
394
- );
395
- return { exitCode: 1, envelope: null };
396
- }),
397
- ]);
398
- const miExit = miResult.exitCode;
399
- const crapExit = crapResult.exitCode;
400
- const miEnvelope = miResult.envelope;
401
- const crapEnvelope = crapResult.envelope;
483
+ // Story #5109 the two gates run **one after the other**, not under a
484
+ // `Promise.all`. Each scores its batch with its own `runOnPool` budget
485
+ // sized to `os.availableParallelism()`, so overlapping them oversubscribed
486
+ // the host by 2x and stacked two escomplex heaps: a 58-file preview peaked
487
+ // at 1.0-1.2 GB RSS for 3.9 s of CPU. Serialising them bounds the preview
488
+ // to one `availableParallelism` of workers and one heap at a time. The two
489
+ // runners share no state and neither reads the other's envelope, so the
490
+ // emitted envelopes and therefore the merged table and the exit code —
491
+ // are identical either way; only the peak cost differs.
492
+ //
493
+ // Each runner gets its own options literal rather than one shared object,
494
+ // so serialising them cannot introduce a coupling the concurrent form
495
+ // did not have.
496
+ const miResult = await runGateSafely(
497
+ runMi,
498
+ { cwd, staged, changedSinceRef: ref },
499
+ 'MI',
500
+ stderr,
501
+ );
502
+ const crapResult = await runGateSafely(
503
+ runCrap,
504
+ { cwd, staged, changedSinceRef: ref },
505
+ 'CRAP',
506
+ stderr,
507
+ );
508
+
402
509
  // Story #4923 — the over-ceiling column counts against the *resolved*
403
510
  // `codingGuardrails.cyclomaticFlag`, not the literal that used to be written
404
511
  // into `mergeEnvelopes` and the column header.
405
512
  const cyclomaticFlag = resolveCyclomaticFlag({ cwd, stderr });
406
- const merged = mergeEnvelopes(miEnvelope, crapEnvelope, { cyclomaticFlag });
513
+ const merged = mergeEnvelopes(miResult.envelope, crapResult.envelope, {
514
+ cyclomaticFlag,
515
+ });
407
516
 
408
- if (json) {
409
- stdout.write(
410
- `${JSON.stringify(
411
- {
412
- ref: staged ? null : ref,
413
- staged,
414
- mi: { exit: miExit, envelope: miEnvelope },
415
- crap: { exit: crapExit, envelope: crapEnvelope },
416
- merged,
417
- },
418
- null,
419
- 2,
420
- )}\n`,
421
- );
422
- } else {
423
- stdout.write('\n--- quality:preview ---\n');
424
- stdout.write(
425
- staged
426
- ? 'scope=staged (git diff --cached)\n\n'
427
- : `scope=diff ref=${ref}\n\n`,
428
- );
429
- stdout.write(`${renderTable(merged)}\n`);
430
- const diagnostics = renderDiagnostics([miResult, crapResult]);
431
- if (diagnostics) stdout.write(`\n${diagnostics}\n`);
432
- if (miExit !== 0 || crapExit !== 0) {
433
- stderr.write(
434
- `\n[quality:preview] gate exits: mi=${miExit} crap=${crapExit}\n`,
435
- );
436
- }
437
- }
517
+ emitReport({
518
+ json,
519
+ staged,
520
+ ref,
521
+ miResult,
522
+ crapResult,
523
+ merged,
524
+ stdout,
525
+ stderr,
526
+ });
438
527
 
439
- return { exitCode: computeExitCode(merged, miExit, crapExit), merged };
528
+ return {
529
+ exitCode: computeExitCode(merged, miResult.exitCode, crapResult.exitCode),
530
+ merged,
531
+ };
440
532
  }
441
533
 
442
534
  // cli-opt-out: Windows-aware main-guard with leading-slash drive-letter normalisation; mirrors quality-watch.js so the diagnostic surface stays consistent across the gate suite.
@@ -69,11 +69,14 @@ export function runTestProfile({
69
69
  const { outDir, topN, testArgv } = parseProfileArgv(argv);
70
70
  fsLike.mkdirSync(outDir, { recursive: true });
71
71
 
72
- const nodeArgs = [
73
- ...buildNodeTestArgs({ extraArgs: testArgv }),
74
- '--test-reporter',
75
- 'tap',
76
- ];
72
+ // The reporter is a *builder option*, not a suffix. Appended after the
73
+ // file targets it was two more positionals to Node, the default reporter
74
+ // ran, and every profile parsed `Timed entries parsed: 0` off a full
75
+ // suite run. `buildNodeTestArgs` places it in flag position.
76
+ const nodeArgs = buildNodeTestArgs({
77
+ extraArgs: testArgv,
78
+ reporter: 'tap',
79
+ });
77
80
 
78
81
  const started = Date.now();
79
82
  const result = spawnChild(process.execPath, nodeArgs, {
@@ -16,16 +16,18 @@
16
16
  * to opt back in for the rare case where a contract test deliberately
17
17
  * exercises a sandbox endpoint.
18
18
  *
19
- * Windows arg-length safety: the `quick` / `integration` tiers enumerate
20
- * explicit test-file targets (they exclude specific slow suites, so a single
21
- * glob will not do). With ~700+ targets the joined command line crosses the
22
- * Windows `CreateProcess` ~32 767-char `lpCommandLine` ceiling, and
23
- * `spawnSync` throws `ENAMETOOLONG` before a single test runs. To stay safe
24
- * on every platform the runner partitions the targets into chunks whose
25
- * joined length stays well under that ceiling (`MAX_TARGET_CHARS`) and spawns
26
- * one `node --test` process per chunk, aggregating the exit codes. The
27
- * `full` tier (a single recursive `tests` glob) yields exactly one chunk, so
28
- * its behaviour is unchanged.
19
+ * Windows arg-length safety: every tier enumerates explicit test-file
20
+ * targets. `quick` / `integration` always did (they exclude specific slow
21
+ * suites, so a single glob will not do), and `full` joined them in Story
22
+ * #5111 `node --test` has no negative pattern, so "everything except
23
+ * `tests/e2e/**`" is only sayable as a file set. With ~700+ targets the
24
+ * joined command line crosses the Windows `CreateProcess` ~32 767-char
25
+ * `lpCommandLine` ceiling, and `spawnSync` throws `ENAMETOOLONG` before a
26
+ * single test runs. To stay safe on every platform the runner partitions the
27
+ * targets into chunks whose joined length stays well under that ceiling
28
+ * (`MAX_TARGET_CHARS`) and spawns one `node --test` process per chunk,
29
+ * aggregating the exit codes. On POSIX the far larger
30
+ * `POSIX_MAX_TARGET_CHARS` budget collapses every tier back into one spawn.
29
31
  */
30
32
 
31
33
  import { spawnSync } from 'node:child_process';
@@ -126,18 +128,38 @@ export function chunkTestTargets(targets, maxChars = MAX_TARGET_CHARS) {
126
128
  }
127
129
 
128
130
  /**
131
+ * Build the argv for one `node --test` invocation.
132
+ *
133
+ * **Flag position is load-bearing.** Node stops treating tokens as options
134
+ * at the first positional, so every runner flag — the fixed
135
+ * `TEST_RUNNER_FLAGS`, the optional `--test-reporter`, and any caller
136
+ * `extraArgs` — has to sit *before* the test-file targets. Appending
137
+ * `--test-reporter tap` after the targets (what `run-test-profile.js` used
138
+ * to do) made Node read the two tokens as two more file patterns: the
139
+ * default reporter ran, no TAP was emitted, and the profiler parsed zero
140
+ * timed entries out of a full suite run. `reporter` is therefore an option
141
+ * of this builder rather than something a caller concatenates on the end.
142
+ *
129
143
  * @param {object} [opts]
130
- * @param {string[]} [opts.extraArgs]
144
+ * @param {string[]} [opts.extraArgs] Extra runner arguments (flag position).
131
145
  * @param {'full' | 'quick' | 'integration'} [opts.tier]
132
146
  * @param {string} [opts.repoRoot]
147
+ * @param {string} [opts.reporter] `--test-reporter` value, e.g. `'tap'`.
148
+ * @returns {string[]}
133
149
  */
134
150
  export function buildNodeTestArgs({
135
151
  extraArgs = [],
136
152
  tier = 'full',
137
153
  repoRoot = ROOT,
154
+ reporter,
138
155
  } = {}) {
139
- const targets = listTestFilesForTier(tier, repoRoot);
140
- return [...TEST_RUNNER_FLAGS, ...targets, ...extraArgs];
156
+ return [
157
+ ...(reporter
158
+ ? [...TEST_RUNNER_FLAGS, '--test-reporter', reporter]
159
+ : TEST_RUNNER_FLAGS),
160
+ ...extraArgs,
161
+ ...listTestFilesForTier(tier, repoRoot),
162
+ ];
141
163
  }
142
164
 
143
165
  export function runTestSuite({
@@ -169,7 +191,13 @@ export function runTestSuite({
169
191
  for (const chunk of chunks) {
170
192
  const testRun = spawn(
171
193
  process.execPath,
172
- [...TEST_RUNNER_FLAGS, ...chunk, ...rest],
194
+ // `rest` carries the documented `node --test` pass-throughs
195
+ // (`--test-name-pattern`, `--test-only`) — `parseTierArgv` has already
196
+ // rejected anything else. They precede the targets for the same reason
197
+ // `buildNodeTestArgs` orders them that way: Node stops parsing options
198
+ // at the first positional, so a flag after a target is silently read as
199
+ // another file pattern.
200
+ [...TEST_RUNNER_FLAGS, ...rest, ...chunk],
173
201
  { cwd, stdio: 'inherit', env },
174
202
  );
175
203
  if (testRun.error) {
@@ -199,7 +227,44 @@ export function runTestSuite({
199
227
  return status;
200
228
  }
201
229
 
230
+ /**
231
+ * `--help` contract for the runner — the pre-rendered shape
232
+ * `lib/cli-usage.js` accepts alongside a spec object, matching how
233
+ * `check-baselines.js` declares its own.
234
+ *
235
+ * Handed to `runAsCli`, which prints it and returns **before** `main` runs.
236
+ * That ordering is the whole point: with no `usage` the flag fell through to
237
+ * `runTestSuite`, which ran the tier preflight, spawned the full ~10 000-test
238
+ * suite, and swept `temp/` — 25+ seconds and a mutated working tree in
239
+ * answer to a question about flags.
240
+ *
241
+ * Exported so the unit test can assert the documented surface without
242
+ * spawning the CLI.
243
+ */
244
+ export const USAGE = `Usage: node .agents/scripts/run-tests.js [--tier <full|quick|integration|e2e>] [runner args...]
245
+
246
+ Run the test suite: tier preflight, \`node --test\` over the tier's targets,
247
+ then reserved-temp cleanup — which runs even when the suite fails.
248
+
249
+ Flags:
250
+ --tier <full|quick|integration|e2e>
251
+ Tier to run. Default: full — every test file except
252
+ \`tests/e2e/**\`, which only \`--tier e2e\` runs.
253
+ --test-name-pattern <re>, --test-only
254
+ The documented \`node --test\` pass-throughs, forwarded
255
+ verbatim in flag position, ahead of the file targets.
256
+ Any other \`--flag\` is rejected rather than forwarded:
257
+ \`node --test\` reads an unrecognized flag as another
258
+ file pattern, so forwarding one ran nothing and
259
+ still exited 0.
260
+ --help Show this message.
261
+
262
+ Exits with the first non-zero \`node --test\` chunk status, or 2 when the tier
263
+ preflight refuses to start the suite.
264
+ `;
265
+
202
266
  runAsCli(import.meta.url, async () => runTestSuite(), {
203
267
  source: 'run-tests',
204
268
  propagateExitCode: true,
269
+ usage: USAGE,
205
270
  });
@@ -237,8 +237,6 @@ runAsCli(import.meta.url, main, {
237
237
  // the point of this flag is that arming stays on the one code path.
238
238
  'Land despite a Story-scope code-review CRITICAL blocker you have reviewed and judged wrong. The reason is mandatory (≥12 chars) and is recorded on the Story, on the PR, and as a `review-block-overridden` friction signal; the terminal envelope reports `gates.codeReview: "overridden"`. Use this instead of merging the PR by hand with the GitHub CLI — a hand-merge bypasses the gate with no record at all.',
239
239
  ],
240
- ['--no-evidence', 'Do not reuse or write gate evidence stamps.'],
241
- ['--dry-run', 'Report the plan; mutate nothing.'],
242
240
  ],
243
241
  notes: [
244
242
  'Exit codes:\n 0 landed\n 1 blocked or failed\n 3 pending (resumable — run the envelope’s nextCommand)',
@@ -73,6 +73,7 @@ import {
73
73
  } from './lib/orchestration/ticketing.js';
74
74
  import { createProvider } from './lib/provider-factory.js';
75
75
  import { buildProtectionCtx } from './lib/single-story-sweep/protection-ctx.js';
76
+ import { resolveSweepLockPath } from './lib/single-story-sweep/sweep-lock.js';
76
77
  // `sweepMergedStoryBranches` is imported dynamically below — its transitive
77
78
  // graph reaches `picomatch` (via `git-cleanup.js`). Loading it statically
78
79
  // would crash module resolution before `assertDepsInstalled()` can emit a
@@ -292,6 +293,9 @@ export function decideStoryBranchSeed({ localHas, remoteHas }) {
292
293
  * in `sweep.protected` for the operator).
293
294
  * - Cross-session lock: a single lockfile under `tempRoot` prevents
294
295
  * two concurrent `/single-story-deliver` invocations from racing.
296
+ * Story #5112 made that lockfile shared with `boot-sweep.js` — the
297
+ * other entry point into the same merged-branch reap — by routing
298
+ * both through `resolveSweepLockPath`.
295
299
  *
296
300
  * Exported for testing.
297
301
  */
@@ -307,7 +311,7 @@ export async function reapMergedStoryBranches({
307
311
  injectedSweep ??
308
312
  (await import('./lib/single-story-sweep.js')).sweepMergedStoryBranches;
309
313
  const tempRoot = config?.project?.paths?.tempRoot ?? 'temp';
310
- const lockPath = path.resolve(cwd, tempRoot, 'single-story-sweep.lock');
314
+ const lockPath = resolveSweepLockPath({ cwd, tempRoot });
311
315
  const lockTimeoutMs =
312
316
  config.delivery?.worktreeIsolation?.sweepLockMs ?? 60_000;
313
317
  try {
package/docs/CHANGELOG.md CHANGED
@@ -15,6 +15,38 @@ All notable changes to this project will be documented in this file.
15
15
  -->
16
16
  <!-- markdownlint-disable-file MD004 MD012 MD037 -->
17
17
 
18
+ ## [2.37.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.36.0...mandrel-v2.37.0) (2026-09-03)
19
+
20
+
21
+ ### ⚠ BREAKING CHANGES
22
+
23
+ * `single-story-close.js` now exits non-zero on `--dry-run` or `--no-evidence` instead of silently ignoring them. Neither flag ever had an effect; a caller passing one was getting a full real close.
24
+
25
+ ### Added
26
+
27
+ * cut the test suite's child-process budget: seed the e2e consumer once and move tests/e2e to its own tier, replace CLI cold-start spawns with in-process calls, and settle the isolation trade-off ([#5111](https://github.com/dsj1984/mandrel/issues/5111)) ([#5122](https://github.com/dsj1984/mandrel/issues/5122)) ([a4fbd56](https://github.com/dsj1984/mandrel/commit/a4fbd5628b8bc11c918e0ff658fe62baf078a17f))
28
+ * detect high-severity advisories on main between PRs, and bound the TypeScript devDependency range ([#5104](https://github.com/dsj1984/mandrel/issues/5104)) ([#5107](https://github.com/dsj1984/mandrel/issues/5107)) ([af8f1b8](https://github.com/dsj1984/mandrel/commit/af8f1b8ee28300391a3a43cca1d43ece5851ce83))
29
+
30
+
31
+ ### Fixed
32
+
33
+ * close five concurrency holes: sweep-lock steal and heartbeat, one lock for the merged-branch reap, atomic pending-cleanup manifest, idempotent createIssue retry, additive lease claim ([#5112](https://github.com/dsj1984/mandrel/issues/5112)) ([#5119](https://github.com/dsj1984/mandrel/issues/5119)) ([8651514](https://github.com/dsj1984/mandrel/commit/86515147381cf653c1e41c01f6405f5333826253))
34
+ * **deps:** bump fast-uri to 3.1.7 for four high-severity advisories ([#5103](https://github.com/dsj1984/mandrel/issues/5103)) ([06c37b1](https://github.com/dsj1984/mandrel/commit/06c37b11952fb8079027c0beceef831bed677485))
35
+ * **drain-pending-cleanup:** honour --no-escalate and guard advertised --no-* flags (refs [#5101](https://github.com/dsj1984/mandrel/issues/5101)) ([#5106](https://github.com/dsj1984/mandrel/issues/5106)) ([52ef3cb](https://github.com/dsj1984/mandrel/commit/52ef3cb0c546714900474c0613d78b23bc40a068))
36
+ * restore the suite profiler, make run-tests --help print usage, and stop three tests burning worker slots on real backoff and a live-repo scan ([#5110](https://github.com/dsj1984/mandrel/issues/5110)) ([#5115](https://github.com/dsj1984/mandrel/issues/5115)) ([dbe0742](https://github.com/dsj1984/mandrel/commit/dbe07420a7d8d3c99016968d9c15e45da64d0de2))
37
+ * single-story-close advertises --dry-run and --no-evidence but never wired either: retire both and reject them loudly ([#5100](https://github.com/dsj1984/mandrel/issues/5100)) ([#5102](https://github.com/dsj1984/mandrel/issues/5102)) ([2f4dd52](https://github.com/dsj1984/mandrel/commit/2f4dd52c682bcc3697f67124d6dc574e209ab204))
38
+
39
+
40
+ ### Performance
41
+
42
+ * stop the baseline gates doing pointless heavy work: lazy TypeScript, small-batch in-process scoring, precompiled globs, diff-scoped cyclomatic, precompiled .agentrc validator ([#5109](https://github.com/dsj1984/mandrel/issues/5109)) ([#5117](https://github.com/dsj1984/mandrel/issues/5117)) ([6b04d5e](https://github.com/dsj1984/mandrel/commit/6b04d5e7c014b4bc052afbef8fd88d85c7a6b00f))
43
+
44
+
45
+ ### Changed
46
+
47
+ * collapse the tripled semver comparator and the copy-pasted migration-step scaffold in the published lib/ payload ([#5113](https://github.com/dsj1984/mandrel/issues/5113)) ([#5116](https://github.com/dsj1984/mandrel/issues/5116)) ([ae2b6d6](https://github.com/dsj1984/mandrel/commit/ae2b6d6c40b26ea6d2f94b07698867863011a659))
48
+ * extract one JavaScript comment stripper, bring two cyclomatic-20 CLI mains under the ceiling, and delete the orchestration payload’s dead code ([#5114](https://github.com/dsj1984/mandrel/issues/5114)) ([#5118](https://github.com/dsj1984/mandrel/issues/5118)) ([747d9a5](https://github.com/dsj1984/mandrel/commit/747d9a5bce66a6230fe6f46d132b3e8aacbc5a09))
49
+
18
50
  ## [2.36.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.35.0...mandrel-v2.36.0) (2026-08-29)
19
51
 
20
52