mandrel 2.66.0 → 2.68.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 (85) hide show
  1. package/.agents/agents/acceptance-critic.md +2 -2
  2. package/.agents/agents/story-worker.md +15 -11
  3. package/.agents/docs/agentrc-reference.json +5 -2
  4. package/.agents/docs/configuration.md +38 -2
  5. package/.agents/docs/workflows.md +4 -2
  6. package/.agents/instructions.md +2 -1
  7. package/.agents/rules/git-conventions-reference.md +5 -5
  8. package/.agents/rules/git-conventions.md +1 -1
  9. package/.agents/schemas/agentrc.schema.json +20 -2
  10. package/.agents/schemas/story-deliver-terminal.schema.json +23 -1
  11. package/.agents/schemas/validation-evidence.schema.json +3 -1
  12. package/.agents/scripts/boot-sweep.js +97 -9
  13. package/.agents/scripts/{git-cleanup.js → clean-git.js} +2 -2
  14. package/.agents/scripts/clean-temp.js +54 -0
  15. package/.agents/scripts/clean-worktrees.js +593 -0
  16. package/.agents/scripts/coverage-capture.js +65 -9
  17. package/.agents/scripts/drain-pending-cleanup.js +5 -4
  18. package/.agents/scripts/evidence-gate.js +106 -8
  19. package/.agents/scripts/lib/baselines/coverage-refresh-scope.js +60 -0
  20. package/.agents/scripts/lib/baselines/crap-updater-cli.js +101 -4
  21. package/.agents/scripts/lib/baselines/refresh-service.js +1 -1
  22. package/.agents/scripts/lib/baselines/seat-missing.js +228 -0
  23. package/.agents/scripts/lib/child-exec.js +39 -1
  24. package/.agents/scripts/lib/clean-temp.js +440 -0
  25. package/.agents/scripts/lib/close-validation/gates.js +59 -19
  26. package/.agents/scripts/lib/close-validation/process.js +23 -24
  27. package/.agents/scripts/lib/close-validation/runner.js +71 -40
  28. package/.agents/scripts/lib/config/gates/coverage.schema.js +21 -0
  29. package/.agents/scripts/lib/config/quality.js +7 -1
  30. package/.agents/scripts/lib/config/temp-paths.js +15 -0
  31. package/.agents/scripts/lib/config-settings-schema-delivery.js +12 -3
  32. package/.agents/scripts/lib/coverage-baseline.js +78 -5
  33. package/.agents/scripts/lib/coverage-capture-affected.js +345 -0
  34. package/.agents/scripts/lib/coverage-capture-delta.js +180 -0
  35. package/.agents/scripts/lib/coverage-capture-fullscope.js +53 -32
  36. package/.agents/scripts/lib/coverage-capture-incremental.js +49 -26
  37. package/.agents/scripts/lib/coverage-capture-usage.js +1 -1
  38. package/.agents/scripts/lib/coverage-capture.js +121 -81
  39. package/.agents/scripts/lib/full-suite-lock.js +49 -46
  40. package/.agents/scripts/lib/full-suite-queue.js +83 -8
  41. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  42. package/.agents/scripts/lib/observability/source-classifier.js +4 -1
  43. package/.agents/scripts/lib/orchestration/code-review.js +15 -3
  44. package/.agents/scripts/lib/orchestration/git-cleanup/phases/cli.js +1 -1
  45. package/.agents/scripts/lib/orchestration/merge-poll.js +5 -0
  46. package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +149 -97
  47. package/.agents/scripts/lib/orchestration/review-deposit.js +219 -0
  48. package/.agents/scripts/lib/orchestration/review-providers/code-review.js +11 -7
  49. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +29 -10
  50. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +124 -73
  51. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +38 -20
  52. package/.agents/scripts/lib/orchestration/single-story-close/phases/lock-wait-pending.js +8 -2
  53. package/.agents/scripts/lib/orchestration/single-story-close/review-overlap.js +161 -0
  54. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +47 -7
  55. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +8 -16
  56. package/.agents/scripts/lib/process-group.js +1 -1
  57. package/.agents/scripts/lib/single-story-sweep.js +2 -2
  58. package/.agents/scripts/lib/supervised-suite.js +247 -0
  59. package/.agents/scripts/lib/temp-removal.js +110 -0
  60. package/.agents/scripts/lib/temp-retention.js +122 -73
  61. package/.agents/scripts/lib/wave-runner/cross-run-overlap.js +120 -0
  62. package/.agents/scripts/lib/wave-runner/live-probe.js +5 -1
  63. package/.agents/scripts/lib/worktree/canonical-path.js +34 -0
  64. package/.agents/scripts/lib/worktree/lifecycle/reap.js +15 -4
  65. package/.agents/scripts/quality-preview.js +112 -14
  66. package/.agents/scripts/single-story-init.js +120 -17
  67. package/.agents/scripts/stories-wave-tick.js +47 -0
  68. package/.agents/scripts/story-review-compute.js +207 -0
  69. package/.agents/scripts/update-coverage-baseline.js +15 -10
  70. package/.agents/scripts/update-crap-baseline.js +12 -2
  71. package/.agents/scripts/update-maintainability-baseline.js +12 -2
  72. package/.agents/workflows/{git-cleanup.md → clean-git.md} +10 -10
  73. package/.agents/workflows/clean-temp.md +67 -0
  74. package/.agents/workflows/clean-worktrees.md +63 -0
  75. package/.agents/workflows/git-deliver.md +1 -1
  76. package/.agents/workflows/helpers/acceptance-self-eval.md +3 -2
  77. package/.agents/workflows/helpers/code-review.md +7 -5
  78. package/.agents/workflows/helpers/deliver-digest.md +39 -36
  79. package/.agents/workflows/helpers/deliver-reference.md +115 -5
  80. package/.agents/workflows/helpers/deliver-story-reference.md +2 -2
  81. package/.agents/workflows/helpers/deliver-story.md +2 -1
  82. package/docs/CHANGELOG.md +39 -0
  83. package/lib/cli/registry.js +125 -18
  84. package/lib/migrations/steps/strip-removed-agentrc-keys.js +0 -5
  85. package/package.json +1 -1
@@ -2,6 +2,28 @@
2
2
  import path from 'node:path';
3
3
  import { resolveChangedFilesRef } from './changed-files.js';
4
4
  import { reportCaptureFailure, stampCapturedTree } from './coverage-capture.js';
5
+ import {
6
+ describeStampFreshness,
7
+ readHeadCommit,
8
+ } from './coverage-capture-delta.js';
9
+
10
+ /**
11
+ * The changed-file set; `null` when incremental mode is off, or (warned)
12
+ * when the ref cannot be resolved.
13
+ *
14
+ * @returns {{ changed: string[] } | null}
15
+ */
16
+ function readChanged({ crap, getChangedFilesImpl, ref, args, logger }) {
17
+ if (crap.incrementalCoverage?.skipWhenUnchanged !== true) return null;
18
+ try {
19
+ return { changed: getChangedFilesImpl({ ref, cwd: args.cwd }) };
20
+ } catch (err) {
21
+ logger.warn(
22
+ `[coverage-capture] ⚠ incremental mode: ${err?.message ?? err} — falling back to full-scope capture.`,
23
+ );
24
+ return null;
25
+ }
26
+ }
5
27
 
6
28
  /**
7
29
  * Under `skipWhenUnchanged`: the changed-file set decides whether to capture,
@@ -20,6 +42,7 @@ import { reportCaptureFailure, stampCapturedTree } from './coverage-capture.js';
20
42
  * runCaptureImpl: Function,
21
43
  * computeContentDigestImpl: Function,
22
44
  * writeCaptureStampImpl: Function,
45
+ * readHeadCommitImpl?: typeof readHeadCommit,
23
46
  * logger: { info: Function, warn: Function, error: Function },
24
47
  * }} opts
25
48
  * @returns {Promise<number | null>}
@@ -34,22 +57,17 @@ export async function tryIncrementalCapture({
34
57
  runCaptureImpl,
35
58
  computeContentDigestImpl,
36
59
  writeCaptureStampImpl,
60
+ readHeadCommitImpl = readHeadCommit,
37
61
  logger,
38
62
  }) {
39
- if (crap.incrementalCoverage?.skipWhenUnchanged !== true) return null;
40
-
41
63
  const ref = resolveChangedFilesRef({ crap, ref: args.ref });
42
- let changed = null;
43
- try {
44
- changed = getChangedFilesImpl({ ref, cwd: args.cwd });
45
- } catch (err) {
46
- logger.warn(
47
- `[coverage-capture] ⚠ incremental mode: ${err?.message ?? err} — falling back to full-scope capture.`,
48
- );
49
- return null;
50
- }
64
+ const read = readChanged({ crap, getChangedFilesImpl, ref, args, logger });
65
+ if (read === null) return null;
51
66
 
52
- const scopedFiles = filterFilesUnderTargetsImpl(changed, crap.targetDirs);
67
+ const scopedFiles = filterFilesUnderTargetsImpl(
68
+ read.changed,
69
+ crap.targetDirs,
70
+ );
53
71
  if (scopedFiles.length === 0) {
54
72
  logger.info(
55
73
  `[coverage-capture] Incremental mode: no changed files under [${crap.targetDirs.join(', ')}] vs ${ref} — skipping capture.`,
@@ -57,35 +75,39 @@ export async function tryIncrementalCapture({
57
75
  return 0;
58
76
  }
59
77
 
60
- const freshness = isCoverageFreshImpl({
61
- coveragePath: crap.coveragePath,
62
- targetDirs: crap.targetDirs,
78
+ const probe = () =>
79
+ isCoverageFreshImpl({
80
+ coveragePath: crap.coveragePath,
81
+ targetDirs: crap.targetDirs,
82
+ cwd: args.cwd,
83
+ requireScope: 'incremental',
84
+ });
85
+ const freshness = probe();
86
+ const detail = describeStampFreshness({
63
87
  cwd: args.cwd,
64
- requireScope: 'incremental',
88
+ coveragePath: crap.coveragePath,
89
+ requiredScope: 'incremental',
90
+ verdict: freshness.reason,
65
91
  });
66
92
  if (freshness.fresh) {
67
93
  logger.info(
68
- `[coverage-capture] Coverage at ${path.resolve(args.cwd, crap.coveragePath)} is ${freshness.reason} (incremental) — skipping capture.`,
94
+ `[coverage-capture] Coverage at ${path.resolve(args.cwd, crap.coveragePath)} is ${freshness.reason} (incremental) — skipping capture. ${detail}`,
69
95
  );
70
96
  return 0;
71
97
  }
72
98
 
73
99
  logger.info(
74
- `[coverage-capture] Incremental mode: ${scopedFiles.length} changed file(s) under [${crap.targetDirs.join(', ')}] — capturing…`,
100
+ `[coverage-capture] Incremental mode: ${scopedFiles.length} changed file(s) under [${crap.targetDirs.join(', ')}] — capturing… ${detail}`,
75
101
  );
76
- // Pre-spawn digest; see `stampCapturedTree`.
102
+ // Pre-spawn digest and commit; see `stampCapturedTree`.
77
103
  const preDigest = computeContentDigestImpl(args.cwd, crap.targetDirs);
104
+ const commit = readHeadCommitImpl(args.cwd);
78
105
  const code = await runCaptureImpl({
79
106
  cwd: args.cwd,
107
+ coveragePath: crap.coveragePath,
80
108
  timeoutMs: coverage?.timeoutMs,
81
109
  log: (m) => logger.info(m),
82
- recheckFresh: () =>
83
- isCoverageFreshImpl({
84
- coveragePath: crap.coveragePath,
85
- targetDirs: crap.targetDirs,
86
- cwd: args.cwd,
87
- requireScope: 'incremental',
88
- }).fresh === true,
110
+ recheckFresh: () => probe().fresh === true,
89
111
  });
90
112
  if (code !== 0) return reportCaptureFailure(code, logger);
91
113
 
@@ -97,6 +119,7 @@ export async function tryIncrementalCapture({
97
119
  scope: 'incremental',
98
120
  files: scopedFiles,
99
121
  ref,
122
+ commit,
100
123
  computeContentDigestImpl,
101
124
  writeCaptureStampImpl,
102
125
  logger,
@@ -14,7 +14,7 @@ const COVERAGE_CAPTURE_USAGE = {
14
14
  invocation:
15
15
  'node .agents/scripts/coverage-capture.js [--skip-when-no-crap-files] [--require-credited] [--ref <git-ref>] [--cwd <path>]',
16
16
  summary:
17
- 'Ensure coverage/coverage-final.json is present and fresh before the CRAP gate fires, spawning `npm run test:coverage` only when it is stale. Writes a content-digest capture stamp that close-validation reads to skip a redundant re-run.',
17
+ 'Ensure coverage/coverage-final.json is present and fresh before the CRAP gate fires, spawning `npm run test:coverage` only when it is stale (`npm run test:coverage:affected`, with the base ref in MANDREL_COVERAGE_BASE_REF, under delivery.quality.gates.coverage.captureScope="affected"). Writes a content-digest capture stamp that close-validation reads to skip a redundant re-run.',
18
18
  flags: [
19
19
  [
20
20
  '--skip-when-no-crap-files',
@@ -3,20 +3,17 @@
3
3
  * reads it: the scorer skips uncovered methods, so a stale artifact silently
4
4
  * weakens the gate.
5
5
  */
6
- import { spawn, spawnSync } from 'node:child_process';
6
+ import { spawnSync } from 'node:child_process';
7
7
  import crypto from 'node:crypto';
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { LOCK_WAIT_EXPIRED_EXIT_CODE } from './full-suite-lock.js';
11
- import {
12
- groupSpawnOptions,
13
- superviseGroup,
14
- TIMEOUT_EXIT_CODE,
15
- } from './process-group.js';
11
+ import { TIMEOUT_EXIT_CODE } from './process-group.js';
16
12
  import {
17
13
  isScorableSourceFile,
18
14
  SCORABLE_SOURCE_EXT_RE,
19
15
  } from './source-extensions.js';
16
+ import { runSupervisedSuite } from './supervised-suite.js';
20
17
 
21
18
  /**
22
19
  * Newest mtime across the scorable sources the CRAP scanner walks; unreadable
@@ -156,18 +153,40 @@ export function computeContentDigest(cwd, targetDirs, io = {}) {
156
153
  }
157
154
  }
158
155
 
156
+ /** @param {string} value */
157
+ const nonEmpty = (value) => typeof value === 'string' && value.length > 0;
158
+
159
+ /** Scoped-stamp fields, each written only when it carries a value. */
160
+ function optionalStampFields({ scope, files, ref, commit }) {
161
+ const out = {};
162
+ if (scope !== undefined) out.scope = scope;
163
+ if (Array.isArray(files)) out.files = [...files].sort();
164
+ if (nonEmpty(ref)) out.ref = ref;
165
+ if (nonEmpty(commit)) out.commit = commit;
166
+ return out;
167
+ }
168
+
169
+ /** @param {Record<string, unknown>} fields */
170
+ function definedOnly(fields) {
171
+ return Object.fromEntries(
172
+ Object.entries(fields).filter(([, value]) => value !== undefined),
173
+ );
174
+ }
175
+
159
176
  /**
160
177
  * Best-effort: a write failure returns `false` (next check falls back to
161
178
  * mtime). Full-scope callers omit `scope`, keeping the `{ digest, capturedAt }`
162
- * shape.
179
+ * shape. `commit` is the HEAD sha the run measured; a stamp without it reads
180
+ * exactly as before and is never delta-refresh eligible.
163
181
  *
164
182
  * @param {{
165
183
  * cwd: string,
166
184
  * coveragePath: string,
167
185
  * digest: string,
168
- * scope?: 'full' | 'incremental',
186
+ * scope?: 'full' | 'incremental' | 'affected',
169
187
  * files?: string[],
170
188
  * ref?: string,
189
+ * commit?: string | null,
171
190
  * writeFileSync?: typeof fs.writeFileSync,
172
191
  * }} opts
173
192
  * @returns {boolean} True when the stamp was written.
@@ -179,13 +198,15 @@ export function writeCaptureStamp({
179
198
  scope,
180
199
  files,
181
200
  ref,
201
+ commit,
182
202
  writeFileSync = fs.writeFileSync,
183
203
  }) {
184
204
  if (typeof digest !== 'string' || digest.length === 0) return false;
185
- const payload = { digest, capturedAt: new Date().toISOString() };
186
- if (scope !== undefined) payload.scope = scope;
187
- if (Array.isArray(files)) payload.files = [...files].sort();
188
- if (typeof ref === 'string' && ref.length > 0) payload.ref = ref;
205
+ const payload = {
206
+ digest,
207
+ capturedAt: new Date().toISOString(),
208
+ ...optionalStampFields({ scope, files, ref, commit }),
209
+ };
189
210
  try {
190
211
  writeFileSync(
191
212
  captureStampPath(cwd, coveragePath),
@@ -197,42 +218,45 @@ export function writeCaptureStamp({
197
218
  }
198
219
  }
199
220
 
221
+ /** Stamp scopes narrower than `full`; each satisfies only its own probe. */
222
+ const PARTIAL_STAMP_SCOPES = new Set(['incremental', 'affected']);
223
+
200
224
  /**
201
225
  * @param {{digest?: unknown, scope?: unknown} | null} stamp
202
- * @param {'full' | 'incremental'} requireScope
226
+ * @param {'full' | 'incremental' | 'affected'} requireScope
203
227
  * @returns {{ digest: string } | { scopeMismatch: true } | null} `null`
204
- * falls through to the mtime heuristic.
228
+ * means no usable stamp: nothing vouches for the artifact.
205
229
  */
206
230
  function readStampForScope(stamp, requireScope) {
207
231
  if (typeof stamp?.digest !== 'string' || stamp.digest.length === 0) {
208
232
  return null;
209
233
  }
210
- const stampScope = stamp.scope === 'incremental' ? 'incremental' : 'full';
211
- if (stampScope === 'incremental' && requireScope !== 'incremental') {
234
+ if (PARTIAL_STAMP_SCOPES.has(stamp.scope) && stamp.scope !== requireScope) {
212
235
  return { scopeMismatch: true };
213
236
  }
214
237
  return { digest: stamp.digest };
215
238
  }
216
239
 
217
240
  /**
218
- * Stamp digest vs current digest when a stamp exists; otherwise artifact
219
- * mtime vs newest source. IO errors resolve stale. Both paths fail closed
220
- * (`no-sources`) on an empty source set — "found nothing" is not "nothing
221
- * changed". An incremental stamp never satisfies a full-scope probe; a stamp
222
- * with no `scope` is full-scope.
241
+ * Only a matching stamp vouches for the artifact, because only a green run
242
+ * writes one; artifact mtime says when a run wrote it, never that the run
243
+ * passed, so a red run's artifact must not read as fresh. Without a verdict,
244
+ * `no-sources` (empty source set — "found nothing" is not "nothing changed")
245
+ * outranks `unstamped`. A partial (incremental / affected) stamp satisfies
246
+ * only a probe of its own scope; a stamp with no `scope` is full-scope.
223
247
  *
224
248
  * @param {{
225
249
  * coveragePath: string,
226
250
  * targetDirs: string[],
227
251
  * cwd: string,
228
- * requireScope?: 'full' | 'incremental',
252
+ * requireScope?: 'full' | 'incremental' | 'affected',
229
253
  * statSync?: typeof fs.statSync,
230
254
  * readdirSync?: typeof fs.readdirSync,
231
255
  * existsSync?: typeof fs.existsSync,
232
256
  * readFileSync?: typeof fs.readFileSync,
233
257
  * computeDigest?: typeof computeContentDigest,
234
258
  * }} opts
235
- * @returns {{ fresh: boolean, reason: 'missing' | 'stale' | 'fresh' | 'no-sources' | 'scope-mismatch' }}
259
+ * @returns {{ fresh: boolean, reason: 'missing' | 'stale' | 'fresh' | 'no-sources' | 'scope-mismatch' | 'unstamped' }}
236
260
  */
237
261
  export function isCoverageFresh({
238
262
  coveragePath,
@@ -254,7 +278,7 @@ export function isCoverageFresh({
254
278
  try {
255
279
  stamp = JSON.parse(readFileSync(stampPath, 'utf8'));
256
280
  } catch {
257
- // Corrupt/unreadable stamp → fall through to the mtime heuristic.
281
+ // Corrupt/unreadable stamp vouches for nothing.
258
282
  }
259
283
  const resolved = readStampForScope(stamp, requireScope);
260
284
  if (resolved?.scopeMismatch) {
@@ -270,21 +294,14 @@ export function isCoverageFresh({
270
294
  }
271
295
  }
272
296
 
273
- let coverageMtime;
274
- try {
275
- coverageMtime = statSync(absCoverage).mtimeMs;
276
- } catch {
277
- return { fresh: false, reason: 'missing' };
278
- }
279
-
280
297
  const newestSrc = newestSourceMtime(cwd, targetDirs, {
281
298
  statSync,
282
299
  readdirSync,
283
300
  });
284
- if (newestSrc === 0) return { fresh: false, reason: 'no-sources' };
285
- return coverageMtime >= newestSrc
286
- ? { fresh: true, reason: 'fresh' }
287
- : { fresh: false, reason: 'stale' };
301
+ return {
302
+ fresh: false,
303
+ reason: newestSrc === 0 ? 'no-sources' : 'unstamped',
304
+ };
288
305
  }
289
306
 
290
307
  /**
@@ -358,9 +375,10 @@ export function creditedCapture(runCaptureFn, { requireCredited, logger }) {
358
375
  * cwd: string,
359
376
  * targetDirs: string[],
360
377
  * coveragePath: string,
361
- * scope?: 'full' | 'incremental',
378
+ * scope?: 'full' | 'incremental' | 'affected',
362
379
  * files?: string[],
363
380
  * ref?: string,
381
+ * commit?: string | null,
364
382
  * computeContentDigestImpl: typeof computeContentDigest,
365
383
  * writeCaptureStampImpl: typeof writeCaptureStamp,
366
384
  * logger: { info: Function, warn: Function, error: Function },
@@ -375,6 +393,7 @@ export function stampCapturedTree({
375
393
  scope,
376
394
  files,
377
395
  ref,
396
+ commit,
378
397
  computeContentDigestImpl,
379
398
  writeCaptureStampImpl,
380
399
  logger,
@@ -393,9 +412,7 @@ export function stampCapturedTree({
393
412
  cwd,
394
413
  coveragePath,
395
414
  digest: preDigest,
396
- ...(scope === undefined ? {} : { scope }),
397
- ...(files === undefined ? {} : { files }),
398
- ...(ref === undefined ? {} : { ref }),
415
+ ...definedOnly({ scope, files, ref, commit: commit || undefined }),
399
416
  });
400
417
  if (written) {
401
418
  logger.info(
@@ -434,73 +451,96 @@ export function anyChangedUnderTargets(changedFiles, targetDirs) {
434
451
  return filterFilesUnderTargets(changedFiles, targetDirs).length > 0;
435
452
  }
436
453
 
454
+ /**
455
+ * @param {{ cwd: string, coveragePath?: string }} opts No `coveragePath`,
456
+ * no stamp to drop.
457
+ */
458
+ function dropCaptureStamp({ cwd, coveragePath }) {
459
+ if (!coveragePath) return;
460
+ fs.rmSync(captureStampPath(cwd, coveragePath), { force: true });
461
+ }
462
+
437
463
  /** GNU `timeout(1)` code, so callers tell a hang (124) from failing tests. */
438
464
  export const COVERAGE_TIMEOUT_EXIT_CODE = TIMEOUT_EXIT_CODE;
439
465
 
440
466
  /**
441
- * Spawn `npm run test:coverage` asynchronously as its own process group, so
467
+ * Spawn `npm run <script>` asynchronously as its own process group, so
442
468
  * the lock heartbeat keeps running and a timeout or signal kills every
443
469
  * worker. Takes no positional file args: node's runner would execute a
444
- * forwarded source file as a test instead of filtering the suite.
470
+ * forwarded source file as a test instead of filtering the suite. Scope a
471
+ * run through `env` instead. Given `coveragePath`, the capture stamp is
472
+ * removed before the spawn: the suite is about to overwrite the artifact, so
473
+ * a red, killed or crashed run must leave no stamp vouching for it — only
474
+ * `stampCapturedTree` after a green run restores one.
445
475
  *
446
476
  * @param {{
447
477
  * cwd: string,
478
+ * coveragePath?: string,
448
479
  * timeoutMs?: number,
449
- * spawnImpl?: typeof spawn,
480
+ * script?: string,
481
+ * env?: Record<string, string>,
482
+ * spawnImpl?: Function,
450
483
  * log?: (m: string) => void,
484
+ * lockWaitMs?: number,
485
+ * onTimings?: (timings: import('./supervised-suite.js').SuiteTimings) => void,
451
486
  * }} opts
452
487
  * @returns {Promise<number>}
453
488
  */
454
- export function runCapture({
455
- cwd,
456
- timeoutMs,
457
- spawnImpl = spawn,
458
- log = () => {},
459
- } = {}) {
460
- const args = ['run', 'test:coverage'];
489
+ export function runCapture(opts = {}) {
490
+ const { script = 'test:coverage', log = () => {} } = opts;
491
+ dropCaptureStamp(opts);
492
+ const args = ['run', script];
461
493
  log(`[coverage-capture] ▶ npm ${args.join(' ')}`);
462
- return new Promise((resolve) => {
463
- const child = spawnImpl('npm', args, {
464
- cwd,
465
- stdio: 'inherit',
466
- shell: process.platform === 'win32',
467
- ...groupSpawnOptions(),
468
- });
469
- const supervisor = superviseGroup(child, { timeoutMs });
470
- child.on('error', () => {
471
- supervisor.release();
472
- resolve(1);
473
- });
474
- child.on('exit', (code) => {
475
- supervisor.release();
476
- if (!supervisor.timedOut) {
477
- resolve(code ?? 1);
478
- return;
479
- }
494
+ return runSupervisedSuite({
495
+ ...opts,
496
+ cmd: 'npm',
497
+ args,
498
+ onTimeout: () =>
480
499
  log(
481
- `[coverage-capture] ⏱ npm run test:coverage exceeded ${timeoutMs}ms — killed its process group. Returning exit ${COVERAGE_TIMEOUT_EXIT_CODE}.`,
482
- );
483
- resolve(COVERAGE_TIMEOUT_EXIT_CODE);
484
- });
500
+ `[coverage-capture] ⏱ npm run ${script} exceeded ${opts.timeoutMs}ms — killed its process group. Returning exit ${COVERAGE_TIMEOUT_EXIT_CODE}.`,
501
+ ),
485
502
  });
486
503
  }
487
504
 
505
+ /** @param {number} code */
506
+ function lockWaitExpiredMessage(code) {
507
+ return `[coverage-capture] ⏸ the full-suite lock wait expired with another suite still running, so this capture was deferred — no suite ran. Exiting ${code}; re-run it once that suite finishes.`;
508
+ }
509
+
510
+ /** @param {number} code */
511
+ function timeoutMessage(code) {
512
+ return `[coverage-capture] ⏱ npm run test:coverage timed out and was killed (exit ${code}) — no test verdict exists. This is usually host contention, not a failing test; re-run once the host is quieter.`;
513
+ }
514
+
515
+ /** @param {number} code */
516
+ function failingSuiteMessage(code) {
517
+ return `[coverage-capture] ✖ npm run test:coverage exited ${code}. Fix failing tests or coverage-threshold breaches before re-running the CRAP gate.`;
518
+ }
519
+
520
+ /**
521
+ * Exits that are not a failing suite, each with its own report: an expired
522
+ * lock wait ran nothing, and a timeout killed the suite before any verdict.
523
+ * Named functions: V8 coverage omitted an inline arrow here, so CRAP could
524
+ * not resolve it.
525
+ */
526
+ const NON_FAILURE_CAPTURE_EXITS = Object.freeze({
527
+ [LOCK_WAIT_EXPIRED_EXIT_CODE]: ['info', lockWaitExpiredMessage],
528
+ [COVERAGE_TIMEOUT_EXIT_CODE]: ['error', timeoutMessage],
529
+ });
530
+
531
+ const FAILING_SUITE_REPORT = Object.freeze(['error', failingSuiteMessage]);
532
+
488
533
  /**
489
- * Report a non-zero capture; an expired lock wait is not a failing suite.
534
+ * Report a non-zero capture; an expired lock wait or a timeout is not a
535
+ * failing suite, so neither prints the failing-tests line.
490
536
  *
491
537
  * @param {number} code
492
538
  * @param {{ info: Function, error: Function }} logger
493
539
  * @returns {number}
494
540
  */
495
541
  export function reportCaptureFailure(code, logger) {
496
- if (code === LOCK_WAIT_EXPIRED_EXIT_CODE) {
497
- logger.info(
498
- `[coverage-capture] ⏸ the full-suite lock wait expired and this capture was deferred — no suite ran. Exiting ${code}.`,
499
- );
500
- return code;
501
- }
502
- logger.error(
503
- `[coverage-capture] ✖ npm run test:coverage exited ${code}. Fix failing tests or coverage-threshold breaches before re-running the CRAP gate.`,
504
- );
542
+ const [level, message] =
543
+ NON_FAILURE_CAPTURE_EXITS[code] ?? FAILING_SUITE_REPORT;
544
+ logger[level](message(code));
505
545
  return code;
506
546
  }
@@ -3,16 +3,16 @@
3
3
  * worktrees (they contend for cores and a shared coverage artifact), over
4
4
  * the `sweep-lock.js` primitive.
5
5
  *
6
- * Best-effort: any failure to acquire spawns anyway — a stale lockfile must
7
- * never fail a delivery. Only close opts in to defer on an expired wait
8
- * ({@link LOCK_WAIT_EXPIRED_EXIT_CODE}). Waits are async so the holder's
9
- * heartbeat and signal release keep working, and FIFO via
10
- * `full-suite-queue.js`. The lock covers only the spawn, never the
11
- * freshness checks before it.
6
+ * It queues, never overlaps: an expired wait behind a live holder spawns
7
+ * nothing ({@link LOCK_WAIT_EXPIRED_EXIT_CODE}). A dead holder is taken over
8
+ * and a lockfile I/O error proceeds unserialized. Waits are async (the
9
+ * holder's heartbeat keeps working) and FIFO via `full-suite-queue.js`; the
10
+ * lock covers only the spawn.
12
11
  */
13
12
  import fs from 'node:fs';
14
13
  import path from 'node:path';
15
14
 
15
+ import { getQuality } from './config/quality.js';
16
16
  import { mainCheckoutRoot } from './config/temp-paths.js';
17
17
  import { isFirstInLine, waitInLine } from './full-suite-queue.js';
18
18
  import { acquireSweepLock } from './single-story-sweep/sweep-lock.js';
@@ -20,20 +20,14 @@ import { acquireSweepLock } from './single-story-sweep/sweep-lock.js';
20
20
  /** Environment escape hatch: set to `0`/`false`/`off`/`no` to disable. */
21
21
  export const FULL_SUITE_LOCK_ENV = 'MANDREL_FULL_SUITE_LOCK';
22
22
 
23
- /** Close sets it to `defer` on its gate children; nothing else does. */
24
- export const FULL_SUITE_LOCK_EXPIRY_ENV = 'MANDREL_FULL_SUITE_LOCK_ON_EXPIRY';
25
-
26
23
  /** `EX_TEMPFAIL`. */
27
24
  export const LOCK_WAIT_EXPIRED_EXIT_CODE = 75;
28
25
 
29
26
  /** Under the main checkout's `.git`, so every worktree shares one file. */
30
27
  const FULL_SUITE_LOCK_FILENAME = 'mandrel-full-suite.lock';
31
28
 
32
- /** Well under close's ten-minute foreground ceiling. */
33
- const DEFAULT_WAIT_MS = 300_000;
34
-
35
- /** Never above the wait budget. */
36
- const DEFAULT_STALE_MS = 240_000;
29
+ /** The wait floor, and the budget when no kill bound is known. */
30
+ const MIN_WAIT_MS = 300_000;
37
31
 
38
32
  const DEFAULT_POLL_MS = 2_000;
39
33
 
@@ -47,16 +41,32 @@ const LOCK_TAG = '[full-suite-lock]';
47
41
  const LOCK_DEFAULTS = Object.freeze({
48
42
  enabled: true,
49
43
  log: () => {},
50
- waitMs: DEFAULT_WAIT_MS,
44
+ ...resolveFullSuiteLockBudget(),
51
45
  pollMs: DEFAULT_POLL_MS,
52
- staleMs: DEFAULT_STALE_MS,
53
46
  reportMs: DEFAULT_REPORT_MS,
54
47
  fsImpl: fs,
55
48
  nowFn: Date.now,
56
49
  sleepFn: (ms) => defaultSleep(ms),
57
50
  acquireOnceFn: (opts) => acquireSweepLock(opts),
51
+ onWaitExpired: () => LOCK_WAIT_EXPIRED_EXIT_CODE,
52
+ rerunCommand: 'the same command',
58
53
  });
59
54
 
55
+ /**
56
+ * The one wait budget every full-suite lock taker uses. A holder's suite is
57
+ * killed at its supervisor's `killBoundMs` (the coverage gate's `timeoutMs`),
58
+ * so waiting that long always observes a release or a death. The stale
59
+ * threshold is four fifths of the wait: never above the budget, and a live
60
+ * holder (heartbeating at a third of its own threshold) is never read stale.
61
+ *
62
+ * @param {number} [killBoundMs]
63
+ * @returns {{ waitMs: number, staleMs: number }}
64
+ */
65
+ export function resolveFullSuiteLockBudget(killBoundMs) {
66
+ const waitMs = Math.max(MIN_WAIT_MS, Number(killBoundMs) || 0);
67
+ return { waitMs, staleMs: waitMs - waitMs / 5 };
68
+ }
69
+
60
70
  /**
61
71
  * Both hatches (env, then config) can only turn the lock off.
62
72
  *
@@ -157,9 +167,10 @@ function consult(probe, applies) {
157
167
  * acquireOnceFn?: typeof acquireSweepLock,
158
168
  * lockPath?: string,
159
169
  * skipIfSatisfied?: () => T|undefined,
160
- * onWaitExpired?: () => T|undefined,
161
- * }} opts A non-`undefined` return from either hook stands in for the spawn.
162
- * @param {() => Promise<T>} spawn
170
+ * onWaitExpired?: (holder: import('./full-suite-queue.js').LockHolder) => T,
171
+ * rerunCommand?: string,
172
+ * }} opts A non-`undefined` `skipIfSatisfied` return stands in for the spawn.
173
+ * @param {(timing: { lockWaitMs: number }) => Promise<T>} spawn
163
174
  * @returns {Promise<T>}
164
175
  */
165
176
  export async function withFullSuiteLockAsync(options, spawn) {
@@ -167,8 +178,8 @@ export async function withFullSuiteLockAsync(options, spawn) {
167
178
  const { lock, lockPath } = beginLock(opts);
168
179
  const wait =
169
180
  lock === null && lockPath !== null
170
- ? await waitInLine({ ...opts, lockPath, expiryNote: expiryNote(opts) })
171
- : { held: lock, expired: false, waited: false };
181
+ ? await waitInLine({ ...opts, lockPath })
182
+ : { held: lock, expired: false, waited: false, waitedMs: 0 };
172
183
  try {
173
184
  return await spawnOrStandIn(opts, wait, spawn);
174
185
  } finally {
@@ -184,12 +195,6 @@ function withDefaults(options) {
184
195
  return opts;
185
196
  }
186
197
 
187
- function expiryNote({ onWaitExpired }) {
188
- return typeof onWaitExpired === 'function'
189
- ? 'not spawning; the caller reports the wait instead'
190
- : 'spawning anyway';
191
- }
192
-
193
198
  async function spawnOrStandIn(opts, wait, spawn) {
194
199
  const probe = consult(opts.skipIfSatisfied, wait.waited);
195
200
  if (probe.satisfied) {
@@ -198,8 +203,8 @@ async function spawnOrStandIn(opts, wait, spawn) {
198
203
  );
199
204
  return probe.value;
200
205
  }
201
- const deferred = consult(opts.onWaitExpired, wait.expired);
202
- return deferred.satisfied ? deferred.value : await spawn();
206
+ if (wait.expired) return opts.onWaitExpired(wait.holder);
207
+ return await spawn({ lockWaitMs: wait.waitedMs ?? 0 });
203
208
  }
204
209
 
205
210
  /**
@@ -212,6 +217,18 @@ function defaultSleep(ms) {
212
217
  });
213
218
  }
214
219
 
220
+ /**
221
+ * @param {object} [config]
222
+ * @param {Record<string, string|undefined>} [env]
223
+ */
224
+ export function fullSuiteLockPolicy(config, env = process.env) {
225
+ return {
226
+ ...resolveFullSuiteLockBudget(getQuality(config).coverage?.timeoutMs),
227
+ enabled: isFullSuiteLockEnabled({ config, env }),
228
+ onWaitExpired: () => LOCK_WAIT_EXPIRED_EXIT_CODE,
229
+ };
230
+ }
231
+
215
232
  /**
216
233
  * Serialize a capture runner's spawn. Wrapped at the one call site below
217
234
  * every skip/freshness decision, so a credited capture never waits.
@@ -219,7 +236,7 @@ function defaultSleep(ms) {
219
236
  * @param {Function} runCaptureFn
220
237
  * @param {object} [config]
221
238
  * @param {Record<string, string|undefined>} [env]
222
- * @param {object} [lockOptions] Test seam only.
239
+ * @param {object} [lockOptions]
223
240
  * @returns {(opts?: object) => Promise<number>}
224
241
  */
225
242
  export function lockedCapture(
@@ -228,11 +245,7 @@ export function lockedCapture(
228
245
  env = process.env,
229
246
  lockOptions = {},
230
247
  ) {
231
- const policy = {
232
- enabled: isFullSuiteLockEnabled({ config, env }),
233
- onWaitExpired: deferredCaptureExit(env),
234
- ...lockOptions,
235
- };
248
+ const policy = { ...fullSuiteLockPolicy(config, env), ...lockOptions };
236
249
  return (captureOpts = {}) =>
237
250
  withFullSuiteLockAsync(
238
251
  {
@@ -241,20 +254,10 @@ export function lockedCapture(
241
254
  log: captureOpts.log,
242
255
  skipIfSatisfied: freshnessProbe(captureOpts),
243
256
  },
244
- () => runCaptureFn(captureOpts),
257
+ ({ lockWaitMs }) => runCaptureFn({ ...captureOpts, lockWaitMs }),
245
258
  );
246
259
  }
247
260
 
248
- /**
249
- * @param {Record<string, string|undefined>} env
250
- * @returns {(() => number)|undefined}
251
- */
252
- function deferredCaptureExit(env) {
253
- return env[FULL_SUITE_LOCK_EXPIRY_ENV] === 'defer'
254
- ? () => LOCK_WAIT_EXPIRED_EXIT_CODE
255
- : undefined;
256
- }
257
-
258
261
  /**
259
262
  * A fresh recheck means exit 0 without spawning.
260
263
  *