mandrel 2.67.0 → 2.69.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 (61) hide show
  1. package/.agents/agents/story-worker.md +15 -11
  2. package/.agents/docs/agentrc-reference.json +3 -1
  3. package/.agents/docs/configuration.md +36 -1
  4. package/.agents/schemas/agentrc.schema.json +14 -1
  5. package/.agents/schemas/story-deliver-terminal.schema.json +23 -1
  6. package/.agents/schemas/validation-evidence.schema.json +3 -1
  7. package/.agents/scripts/coverage-capture.js +65 -9
  8. package/.agents/scripts/evidence-gate.js +106 -8
  9. package/.agents/scripts/lib/baselines/coverage-refresh-scope.js +60 -0
  10. package/.agents/scripts/lib/baselines/crap-updater-cli.js +101 -4
  11. package/.agents/scripts/lib/baselines/refresh-service.js +1 -1
  12. package/.agents/scripts/lib/baselines/seat-missing.js +228 -0
  13. package/.agents/scripts/lib/child-exec.js +39 -1
  14. package/.agents/scripts/lib/close-validation/gates.js +59 -19
  15. package/.agents/scripts/lib/close-validation/process.js +23 -24
  16. package/.agents/scripts/lib/close-validation/runner.js +71 -40
  17. package/.agents/scripts/lib/config/gates/coverage.schema.js +21 -0
  18. package/.agents/scripts/lib/config/quality.js +7 -1
  19. package/.agents/scripts/lib/config/temp-paths.js +15 -0
  20. package/.agents/scripts/lib/config-settings-schema-delivery.js +1 -1
  21. package/.agents/scripts/lib/coverage-baseline.js +78 -5
  22. package/.agents/scripts/lib/coverage-capture-affected.js +345 -0
  23. package/.agents/scripts/lib/coverage-capture-delta.js +180 -0
  24. package/.agents/scripts/lib/coverage-capture-fullscope.js +53 -32
  25. package/.agents/scripts/lib/coverage-capture-incremental.js +49 -26
  26. package/.agents/scripts/lib/coverage-capture-usage.js +1 -1
  27. package/.agents/scripts/lib/coverage-capture.js +121 -81
  28. package/.agents/scripts/lib/full-suite-lock.js +49 -46
  29. package/.agents/scripts/lib/full-suite-queue.js +83 -8
  30. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  31. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  32. package/.agents/scripts/lib/orchestration/code-review.js +15 -3
  33. package/.agents/scripts/lib/orchestration/merge-poll.js +5 -0
  34. package/.agents/scripts/lib/orchestration/review-deposit.js +219 -0
  35. package/.agents/scripts/lib/orchestration/review-providers/code-review.js +11 -7
  36. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +29 -10
  37. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +124 -73
  38. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +38 -20
  39. package/.agents/scripts/lib/orchestration/single-story-close/phases/lock-wait-pending.js +8 -2
  40. package/.agents/scripts/lib/orchestration/single-story-close/review-overlap.js +161 -0
  41. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +47 -7
  42. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +8 -16
  43. package/.agents/scripts/lib/process-group.js +1 -1
  44. package/.agents/scripts/lib/supervised-suite.js +247 -0
  45. package/.agents/scripts/lib/wave-runner/cross-run-overlap.js +120 -0
  46. package/.agents/scripts/lib/wave-runner/live-probe.js +5 -1
  47. package/.agents/scripts/quality-preview.js +112 -14
  48. package/.agents/scripts/stories-wave-tick.js +47 -0
  49. package/.agents/scripts/story-review-compute.js +207 -0
  50. package/.agents/scripts/update-coverage-baseline.js +15 -10
  51. package/.agents/scripts/update-crap-baseline.js +12 -2
  52. package/.agents/scripts/update-maintainability-baseline.js +12 -2
  53. package/.agents/workflows/audit-security.md +0 -1
  54. package/.agents/workflows/helpers/code-review.md +7 -5
  55. package/.agents/workflows/helpers/deliver-digest.md +39 -36
  56. package/.agents/workflows/helpers/deliver-reference.md +115 -5
  57. package/.agents/workflows/helpers/deliver-story.md +2 -1
  58. package/docs/CHANGELOG.md +33 -0
  59. package/lib/cli/registry.js +125 -18
  60. package/lib/migrations/steps/strip-removed-agentrc-keys.js +0 -5
  61. package/package.json +1 -1
@@ -6,9 +6,17 @@
6
6
  */
7
7
 
8
8
  import path from 'node:path';
9
+ import { getBaselines, getQuality, resolveConfig } from '../config-resolver.js';
10
+ import { isCoverageFresh } from '../coverage-capture.js';
11
+ import { loadCoverage as loadCoverageDefault } from '../coverage-utils.js';
9
12
  import { checkResolutionFloor, scanAndScore } from '../crap-utils.js';
10
13
  import { Logger } from '../Logger.js';
11
14
  import { parseDiffScopeFlag } from './diff-scope-cli.js';
15
+ import {
16
+ checkSeatResolution,
17
+ runSeatMissing,
18
+ SeatRefusal,
19
+ } from './seat-missing.js';
12
20
 
13
21
  const DEFAULT_COVERAGE_PATH = 'coverage/coverage-final.json';
14
22
 
@@ -20,7 +28,7 @@ const DEFAULT_MIN_RESOLUTION_RATE = 0.75;
20
28
  *
21
29
  * @param {string[]} [argv]
22
30
  * @returns {{baselinePath: string|undefined, coveragePath: string|undefined,
23
- * fullScope: boolean, diffScopeRef: string|null}}
31
+ * fullScope: boolean, diffScopeRef: string|null, seatMissing: boolean}}
24
32
  */
25
33
  export function parseCrapUpdaterArgs(argv = []) {
26
34
  const out = {
@@ -28,6 +36,7 @@ export function parseCrapUpdaterArgs(argv = []) {
28
36
  coveragePath: undefined,
29
37
  fullScope: false,
30
38
  diffScopeRef: parseDiffScopeFlag(argv),
39
+ seatMissing: argv.includes('--seat-missing'),
31
40
  };
32
41
  for (let i = 0; i < argv.length; i += 1) {
33
42
  if (argv[i] === '--baseline' && argv[i + 1]) {
@@ -45,16 +54,17 @@ export function parseCrapUpdaterArgs(argv = []) {
45
54
 
46
55
  /**
47
56
  * Flag → config → default. Throws on `--full-scope` with `--diff-scope`:
48
- * silently preferring one would write a baseline nobody asked for.
57
+ * silently preferring one would write a baseline nobody asked for
58
+ * (`runSeatMissing` refuses the `--seat-missing` pairing).
49
59
  *
50
60
  * @param {{baselinePath?: string, coveragePath?: string, fullScope?: boolean,
51
- * diffScopeRef?: string|null}} args
61
+ * diffScopeRef?: string|null, seatMissing?: boolean}} args
52
62
  * @param {{crap?: object, baselines?: object}} sources
53
63
  * @param {string} [cwd]
54
64
  * @returns {{targetDirs: string[], ignoreGlobs: string[],
55
65
  * requireCoverage: boolean, minMethodResolutionRate: number,
56
66
  * coveragePath: string, baselinePath: string, absBaselinePath: string,
57
- * fullScope: boolean, diffScopeRef: string|null}}
67
+ * fullScope: boolean, diffScopeRef: string|null, seatMissing: boolean}}
58
68
  */
59
69
  export function resolveCrapUpdaterOptions(
60
70
  args = {},
@@ -82,6 +92,7 @@ export function resolveCrapUpdaterOptions(
82
92
  : path.resolve(cwd, baselinePath),
83
93
  fullScope: Boolean(args.fullScope),
84
94
  diffScopeRef: args.diffScopeRef ?? null,
95
+ seatMissing: Boolean(args.seatMissing),
85
96
  };
86
97
  }
87
98
 
@@ -172,3 +183,89 @@ export function buildCrapUpdaterScorer(
172
183
  );
173
184
  };
174
185
  }
186
+
187
+ /** Stamp scopes a capture may have written; any one fresh stamp suffices. */
188
+ const CAPTURE_SCOPES = ['full', 'incremental', 'affected'];
189
+
190
+ /**
191
+ * The `--seat-missing` scorer: refuses (throws {@link SeatRefusal}) unless
192
+ * the coverage artifact is fresh for the current tree and every in-scope
193
+ * method resolved a coverage entry — a wrong-coordinate row stays wrong even
194
+ * when it is only inserted.
195
+ *
196
+ * @param {ReturnType<typeof resolveCrapUpdaterOptions>} options
197
+ * @param {{loadCoverage: Function, scan?: Function, isFresh?: Function,
198
+ * cwd?: string, logger?: object}} deps
199
+ * @returns {(files: string[]) => Promise<object[]>}
200
+ */
201
+ export function buildCrapSeatScorer(
202
+ options,
203
+ {
204
+ loadCoverage,
205
+ scan = scanAndScore,
206
+ isFresh = isCoverageFresh,
207
+ cwd = process.cwd(),
208
+ logger = Logger,
209
+ } = {},
210
+ ) {
211
+ const fixCommand = `node .agents/scripts/coverage-capture.js --cwd ${cwd}`;
212
+ return async (files) => {
213
+ const fresh = CAPTURE_SCOPES.some(
214
+ (requireScope) =>
215
+ isFresh({
216
+ coveragePath: options.coveragePath,
217
+ targetDirs: options.targetDirs,
218
+ cwd,
219
+ requireScope,
220
+ }).fresh,
221
+ );
222
+ const coverage = fresh
223
+ ? loadCoverage(path.resolve(cwd, options.coveragePath))
224
+ : null;
225
+ if (!coverage) {
226
+ throw new SeatRefusal(
227
+ `[CRAP] --seat-missing refused: no coverage artifact at ${options.coveragePath} is fresh for the current tree ` +
228
+ `(method resolution unmeasured; unresolved files: ${files.join(', ')}).\n` +
229
+ `Fix: re-capture coverage for the current tree — ${fixCommand}`,
230
+ );
231
+ }
232
+ const summary = await scan({
233
+ targetDirs: options.targetDirs,
234
+ coverage,
235
+ requireCoverage: options.requireCoverage,
236
+ cwd,
237
+ ignoreGlobs: options.ignoreGlobs,
238
+ scopeFiles: files,
239
+ });
240
+ reportScanSummary(summary, logger);
241
+ const refusal = checkSeatResolution(summary.resolution, fixCommand);
242
+ if (refusal) throw new SeatRefusal(refusal);
243
+ return (summary.rows ?? []).filter(
244
+ (r) => typeof r?.crap === 'number' && Number.isFinite(r.crap),
245
+ );
246
+ };
247
+ }
248
+
249
+ /**
250
+ * `update-crap-baseline.js --seat-missing`: seat the diff's new methods
251
+ * through {@link buildCrapSeatScorer}. Resolves to the exit code.
252
+ *
253
+ * @param {string[]} argv
254
+ * @param {{config?: object}} [deps]
255
+ * @returns {Promise<number>}
256
+ */
257
+ export function seatCrapBaseline(argv, { config = resolveConfig() } = {}) {
258
+ const options = resolveCrapUpdaterOptions(parseCrapUpdaterArgs(argv), {
259
+ crap: getQuality(config).crap,
260
+ baselines: getBaselines(config),
261
+ });
262
+ return runSeatMissing({
263
+ kind: 'crap',
264
+ label: 'CRAP',
265
+ writePath: options.absBaselinePath,
266
+ diffScopeRef: options.diffScopeRef,
267
+ fullScope: options.fullScope,
268
+ baseBranch: config.project.baseBranch,
269
+ score: buildCrapSeatScorer(options, { loadCoverage: loadCoverageDefault }),
270
+ });
271
+ }
@@ -507,7 +507,7 @@ async function resolveScope({
507
507
  * @param {typeof nodeFs} fs
508
508
  * @returns {object | null}
509
509
  */
510
- function readPriorEnvelope(writePath, fs) {
510
+ export function readPriorEnvelope(writePath, fs) {
511
511
  let raw;
512
512
  try {
513
513
  raw = fs.readFileSync(writePath, 'utf8');
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Insert-only baseline seating (`--seat-missing`): write ONLY rows whose seat
3
+ * key is absent from the committed baseline; prior rows keep their parsed
4
+ * objects, so they serialise byte-identically. The CRAP key is
5
+ * `path::method` — the gate matches a moved method by name, so it is not
6
+ * missing.
7
+ */
8
+
9
+ import nodeFs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { execFileCaptureAsync } from '../child-exec.js';
12
+ import { getBaselines, resolveConfig } from '../config-resolver.js';
13
+ import { Logger } from '../Logger.js';
14
+ import { parseDiffScopeFlag } from './diff-scope-cli.js';
15
+ import { assertEnvelope } from './envelope.js';
16
+ import { getKindModule } from './kernel.js';
17
+ import { canonicalizeBaselinePath } from './path-canon.js';
18
+ import {
19
+ deriveScopeFromDiff,
20
+ fileFilterFor,
21
+ readPriorEnvelope,
22
+ resolveDefaultScorer,
23
+ } from './refresh-service.js';
24
+ import {
25
+ write as writeEnvelope,
26
+ writeFile as writeEnvelopeFile,
27
+ } from './writer.js';
28
+
29
+ /** Thrown when a seat must not write; the CLI prints `message` and exits 1. */
30
+ export class SeatRefusal extends Error {
31
+ constructor(message) {
32
+ super(message);
33
+ this.name = 'SeatRefusal';
34
+ }
35
+ }
36
+
37
+ const SEAT_KEYS = Object.freeze({
38
+ crap: (row) => `${row.path}::${row.method}`,
39
+ maintainability: (row) => row.path,
40
+ });
41
+
42
+ /**
43
+ * @param {string} kind
44
+ * @returns {(row: object) => string}
45
+ */
46
+ export function seatKeyFor(kind) {
47
+ const key = SEAT_KEYS[kind];
48
+ if (!key) throw new Error(`seat-missing: unsupported kind "${kind}"`);
49
+ return key;
50
+ }
51
+
52
+ /**
53
+ * Candidate rows whose seat key no prior row carries. Pure.
54
+ *
55
+ * @param {{kind: string, priorRows: object[], candidateRows: object[]}} args
56
+ * @returns {object[]}
57
+ */
58
+ export function selectMissingRows({ kind, priorRows, candidateRows }) {
59
+ const key = seatKeyFor(kind);
60
+ const present = new Set((priorRows ?? []).map(key));
61
+ return (candidateRows ?? []).filter((row) => !present.has(key(row)));
62
+ }
63
+
64
+ /**
65
+ * The CRAP seat's fail-closed precondition: every joinable method in scope
66
+ * resolved a coverage entry. Returns the refusal text, or `null` to proceed.
67
+ * Anything below 100% means the artifact's coordinates predate the tree.
68
+ *
69
+ * @param {{resolvedMethods?: number, joinableMethods?: number, rate?: number,
70
+ * worstFiles?: Array<{file: string, unresolved: number, total: number}>}
71
+ * | undefined} resolution
72
+ * @param {string} fixCommand
73
+ * @returns {string|null}
74
+ */
75
+ export function checkSeatResolution(resolution, fixCommand) {
76
+ const { joinableMethods = 0, resolvedMethods = 0 } = resolution ?? {};
77
+ if (resolvedMethods >= joinableMethods) return null;
78
+ const rate = ((resolvedMethods / joinableMethods) * 100).toFixed(1);
79
+ const files = (resolution.worstFiles ?? [])
80
+ .map((w) => ` - ${w.file} (${w.unresolved}/${w.total} unresolved)`)
81
+ .join('\n');
82
+ return (
83
+ `[CRAP] --seat-missing refused: method resolution ${resolvedMethods}/${joinableMethods} ` +
84
+ `(${rate}%) over the in-scope files; seating requires 100%.\n` +
85
+ (files ? `Unresolved files:\n${files}\n` : '') +
86
+ `Fix: re-capture coverage for the current tree — ${fixCommand}`
87
+ );
88
+ }
89
+
90
+ /**
91
+ * Three-dot range (from the merge-base): the files this branch changed.
92
+ *
93
+ * @param {{baseRef: string, headRef: string, cwd: string}} args
94
+ * @returns {Promise<string[]>}
95
+ */
96
+ async function mergeBaseGitDiff({ baseRef, headRef, cwd }) {
97
+ const { stdout } = await execFileCaptureAsync(
98
+ 'git',
99
+ ['diff', '--name-only', `${baseRef}...${headRef}`],
100
+ { cwd },
101
+ );
102
+ return stdout.split(/\r?\n/).filter((line) => line.trim().length > 0);
103
+ }
104
+
105
+ /**
106
+ * Prior rows are kept as parsed; only the seated rows are projected. With no
107
+ * prior baseline the writer builds a fresh envelope.
108
+ */
109
+ function buildSeatedEnvelope({ kind, priorEnvelope, seated }) {
110
+ if (!priorEnvelope) return writeEnvelope({ kind, rows: seated });
111
+ const mod = getKindModule(kind);
112
+ const envelope = {
113
+ ...priorEnvelope,
114
+ rows: mod.sortRows([...priorEnvelope.rows, ...seated.map(mod.projectRow)]),
115
+ };
116
+ assertEnvelope(envelope);
117
+ return envelope;
118
+ }
119
+
120
+ /**
121
+ * Seat the missing rows of `kind` for the files changed in `baseRef...HEAD`.
122
+ * `score(files)` may throw a {@link SeatRefusal}. No missing row, no write.
123
+ *
124
+ * @param {{kind: string, writePath: string, score: Function, baseRef: string,
125
+ * headRef?: string, cwd?: string, gitDiff?: Function, fs?: typeof nodeFs}} opts
126
+ * @returns {Promise<{seated: number, wrote: boolean, files: string[]}>}
127
+ */
128
+ export async function seatMissingBaseline({
129
+ kind,
130
+ writePath,
131
+ score,
132
+ baseRef,
133
+ headRef = 'HEAD',
134
+ cwd = process.cwd(),
135
+ gitDiff = mergeBaseGitDiff,
136
+ fs = nodeFs,
137
+ }) {
138
+ const files = await deriveScopeFromDiff({
139
+ baseRef,
140
+ headRef,
141
+ predicate: fileFilterFor(kind),
142
+ gitDiff,
143
+ cwd,
144
+ });
145
+ if (files.length === 0) return { seated: 0, wrote: false, files };
146
+
147
+ const candidateRows = (await score(files)).map((row) => ({
148
+ ...row,
149
+ path: canonicalizeBaselinePath(row.path ?? row.file),
150
+ }));
151
+ const priorEnvelope = readPriorEnvelope(writePath, fs);
152
+ const seated = selectMissingRows({
153
+ kind,
154
+ priorRows: priorEnvelope?.rows,
155
+ candidateRows,
156
+ });
157
+ if (seated.length === 0) return { seated: 0, wrote: false, files };
158
+
159
+ const envelope = buildSeatedEnvelope({ kind, priorEnvelope, seated });
160
+ writeEnvelopeFile(writePath, envelope, { fsImpl: fs });
161
+ return { seated: seated.length, wrote: true, files };
162
+ }
163
+
164
+ /**
165
+ * The updater CLIs' `--seat-missing` entry: prints `seated: N`; a
166
+ * {@link SeatRefusal} is an expected outcome, so exit 1 with its message.
167
+ *
168
+ * @param {{kind: string, label: string, writePath: string,
169
+ * diffScopeRef?: string|null, fullScope?: boolean, baseBranch?: string,
170
+ * score?: Function, cwd?: string, logger?: object, seat?: Function}} opts
171
+ * @returns {Promise<number>} The exit code.
172
+ */
173
+ export async function runSeatMissing({
174
+ kind,
175
+ label,
176
+ writePath,
177
+ diffScopeRef = null,
178
+ fullScope = false,
179
+ baseBranch = 'main',
180
+ score,
181
+ cwd = process.cwd(),
182
+ logger = Logger,
183
+ seat = seatMissingBaseline,
184
+ }) {
185
+ if (fullScope) {
186
+ throw new Error(
187
+ `[${label}] --full-scope is incompatible with --seat-missing; pick one`,
188
+ );
189
+ }
190
+ try {
191
+ const { seated } = await seat({
192
+ kind,
193
+ writePath,
194
+ baseRef: diffScopeRef ?? `origin/${baseBranch}`,
195
+ score: score ?? ((files) => resolveDefaultScorer(kind, { cwd })(files)),
196
+ cwd,
197
+ });
198
+ logger.info(`[${label}] seated: ${seated}`);
199
+ return 0;
200
+ } catch (err) {
201
+ if (!(err instanceof SeatRefusal)) throw err;
202
+ logger.error(err.message);
203
+ return 1;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * `update-maintainability-baseline.js --seat-missing`. MI is static, so the
209
+ * default scorer needs no coverage precondition. Resolves to the exit code.
210
+ *
211
+ * @param {string[]} argv
212
+ * @param {{config?: object, cwd?: string}} [deps]
213
+ * @returns {Promise<number>}
214
+ */
215
+ export function seatMaintainabilityBaseline(
216
+ argv,
217
+ { config = resolveConfig(), cwd = process.cwd() } = {},
218
+ ) {
219
+ return runSeatMissing({
220
+ kind: 'maintainability',
221
+ label: 'Maintainability',
222
+ writePath: path.resolve(cwd, getBaselines(config).maintainability.path),
223
+ diffScopeRef: parseDiffScopeFlag(argv),
224
+ fullScope: argv.includes('--full-scope'),
225
+ baseBranch: config.project.baseBranch,
226
+ cwd,
227
+ });
228
+ }
@@ -101,7 +101,10 @@ export function spawnChild(file, args, opts = {}) {
101
101
  * @returns {{ status: number, stdout: string, stderr: string }}
102
102
  */
103
103
  export function spawnCapture(file, args, opts = {}) {
104
- const result = spawnChild(file, args, opts);
104
+ return normalizeCapture(spawnChild(file, args, opts));
105
+ }
106
+
107
+ function normalizeCapture(result) {
105
108
  return {
106
109
  status: result?.status ?? 1,
107
110
  stdout: (result?.stdout ?? '').toString().trim(),
@@ -109,6 +112,41 @@ export function spawnCapture(file, args, opts = {}) {
109
112
  };
110
113
  }
111
114
 
115
+ /**
116
+ * The async runner behind `spawnCaptureAsync`: `input` rides stdin; a
117
+ * timeout, overflow or spawn error resolves with a non-numeric status.
118
+ *
119
+ * @returns {Promise<{ status: number|null, stdout: string, stderr: string }>}
120
+ */
121
+ function execCollect(file, args, { input, ...options }) {
122
+ return new Promise((resolve) => {
123
+ const child = execFile(file, args, options, (error, stdout, stderr) => {
124
+ const code = error ? error.code : 0;
125
+ const status = Number.isInteger(code) ? code : null;
126
+ const reason = status === null ? error.message : '';
127
+ resolve({ status, stdout, stderr: stderr || reason });
128
+ });
129
+ child.stdin?.on('error', () => {});
130
+ child.stdin?.end(input ?? '');
131
+ });
132
+ }
133
+
134
+ /**
135
+ * `spawnCapture` without blocking the event loop: concurrent children keep
136
+ * draining while this one runs. Same normalisation (`null` → 1, trimmed).
137
+ *
138
+ * @param {string} file
139
+ * @param {string[]} args
140
+ * @param {object} [opts]
141
+ * @param {Function} [opts.run] - async (or sync) `(file, args, options)` seam.
142
+ * @returns {Promise<{ status: number, stdout: string, stderr: string }>}
143
+ */
144
+ export async function spawnCaptureAsync(file, args, opts = {}) {
145
+ return normalizeCapture(
146
+ await spawnChild(file, args, { run: execCollect, ...opts }),
147
+ );
148
+ }
149
+
112
150
  /**
113
151
  * `status=null` is printed verbatim: it means the child was killed.
114
152
  *
@@ -4,7 +4,7 @@ import { existsSync } from 'node:fs';
4
4
 
5
5
  import { _internals as baselineReaderInternals } from '../baselines/reader.js';
6
6
  import { getChangedFiles } from '../changed-files.js';
7
- import { getQuality } from '../config/quality.js';
7
+ import { COVERAGE_GATE_DEFAULTS, getQuality } from '../config/quality.js';
8
8
  import { filterFilesUnderTargets } from '../coverage-capture.js';
9
9
  import { hasNpmScript, readPackageScripts } from '../npm-scripts.js';
10
10
  import { KNOWN_KINDS } from '../orchestration/check-baselines/phases/parse-args.js';
@@ -290,41 +290,75 @@ function coverageCaptureRunsSuite({
290
290
  return coverageCaptureActive && !captureSkipPredicted && !testCredited;
291
291
  }
292
292
 
293
+ /**
294
+ * Names every suite the capture can run: under `captureScope: "affected"` a
295
+ * red may come from a delta refresh the base-sync triggered, not the Story.
296
+ */
297
+ const COVERAGE_CAPTURE_HINT =
298
+ 'Coverage capture failed — its suite run (`npm run test:coverage`, or `npm run test:coverage:affected` under `captureScope: "affected"`, including a delta refresh keyed on the stamped commit after a base-sync) exited non-zero. The coverage-capture log names which ran; fix the failing tests or coverage-threshold breaches, then re-run close.';
299
+
293
300
  const COVERAGE_CAPTURE_ARGS = Object.freeze([
294
301
  '.agents/scripts/coverage-capture.js',
295
302
  ]);
296
303
 
297
- /** The close gate that replays the `pre-push` hook's CRAP-scope preview. */
298
- const QUALITY_PREVIEW_GATE_NAME = 'quality-preview';
304
+ /**
305
+ * The `pre-push` hook's quality preview, split at close into its two halves.
306
+ * The MI half needs no coverage artifact, so it fails in the parallel phase;
307
+ * the CRAP half stays serial behind coverage-capture. Every name MUST be in
308
+ * the `gateName` enum of `validation-evidence.schema.json`.
309
+ */
310
+ const QUALITY_PREVIEW_GATE_NAMES = Object.freeze({
311
+ maintainability: 'quality-preview-mi',
312
+ crap: 'quality-preview-crap',
313
+ });
299
314
 
300
- const QUALITY_PREVIEW_HINT =
301
- "Quality preview failed — the same per-file maintainability / CRAP check the `pre-push` hook runs, scored against the base branch. Reduce the flagged methods' complexity or cover them, then re-run close; a close that skipped this gate would have died at push instead.";
315
+ const QUALITY_PREVIEW_HINTS = Object.freeze({
316
+ maintainability:
317
+ 'Maintainability preview failed — the per-file MI check the `pre-push` hook runs, scored against the base branch. Simplify the flagged file(s), then re-run close; this gate runs before coverage-capture, so no suite was spent.',
318
+ crap: "CRAP preview failed — the per-file CRAP check the `pre-push` hook runs, scored against the base branch and the fresh coverage artifact. Reduce the flagged methods' complexity or cover them, then re-run close; a close that skipped this gate would have died at push instead.",
319
+ });
302
320
 
303
321
  /**
304
- * Replays the `pre-push` CRAP-scope preview so its breach fails close, not
305
- * the push. Registered exactly when coverage-capture is, and after it, so it
306
- * scores a fresh artifact.
322
+ * Replays the `pre-push` quality preview as two single-half gates, so an MI
323
+ * breach fails close before the suite runs and a CRAP breach fails it, not
324
+ * the push. Registered exactly when coverage-capture is.
307
325
  *
308
326
  * @param {{ coverageCaptureActive: boolean, baseBranch?: string }} opts
309
- * @returns {Gate[]}
327
+ * @returns {{ maintainability: Gate[], crap: Gate[] }}
310
328
  */
311
- function buildQualityPreviewGateEntry({ coverageCaptureActive, baseBranch }) {
312
- if (!coverageCaptureActive) return [];
329
+ function buildQualityPreviewGateEntries({ coverageCaptureActive, baseBranch }) {
330
+ if (!coverageCaptureActive) return { maintainability: [], crap: [] };
313
331
  const ref = `origin/${baseBranch || 'main'}`;
314
- return [
332
+ const entry = (half, only) => [
315
333
  {
316
- name: QUALITY_PREVIEW_GATE_NAME,
334
+ name: QUALITY_PREVIEW_GATE_NAMES[half],
317
335
  cmd: 'node',
318
- args: ['.agents/scripts/quality-preview.js', '--changed-since', ref],
319
- hint: QUALITY_PREVIEW_HINT,
336
+ args: [
337
+ '.agents/scripts/quality-preview.js',
338
+ '--only',
339
+ only,
340
+ '--changed-since',
341
+ ref,
342
+ ],
343
+ hint: QUALITY_PREVIEW_HINTS[half],
320
344
  },
321
345
  ];
346
+ return {
347
+ maintainability: entry('maintainability', 'mi'),
348
+ crap: entry('crap', 'crap'),
349
+ };
322
350
  }
323
351
 
352
+ /**
353
+ * Shown instead of a gate's own hint when it exits `COVERAGE_TIMEOUT_EXIT_CODE`
354
+ * — a killed suite, not a failing one.
355
+ */
356
+ export const GATE_TIMEOUT_HINT = `The gate outran the suite timeout (\`delivery.quality.gates.coverage.timeoutMs\`, default ${COVERAGE_GATE_DEFAULTS.timeoutMs} ms — raise it for a slow or shared host) and its process group was killed — no test verdict exists. This is usually host contention (sibling suites or a peer close sharing the machine), not a failing test: re-run close once the host is quieter.`;
357
+
324
358
  /**
325
359
  * Build the close-validation gate list, cheapest fast-fail first: typecheck →
326
- * lint → [test] → format → [coverage-capture → quality-preview] →
327
- * check-baselines. Coverage-capture registers only when CRAP is enabled AND a
360
+ * lint → [test] → format → [quality-preview-mi] → [coverage-capture →
361
+ * quality-preview-crap] → check-baselines. Coverage-capture registers only when CRAP is enabled AND a
328
362
  * `test:coverage` script exists; it then carries test-failure signalling and
329
363
  * the plain `test` gate is dropped, so there is always exactly one test gate.
330
364
  *
@@ -392,6 +426,10 @@ export function buildDefaultGates({
392
426
  enabledKinds: baselineKinds,
393
427
  presentBaselines,
394
428
  });
429
+ const qualityPreview = buildQualityPreviewGateEntries({
430
+ coverageCaptureActive,
431
+ baseBranch,
432
+ });
395
433
  if (!baselinesDecision.register && baselinesDecision.reason) {
396
434
  log?.(`[close-validation] ${baselinesDecision.reason}`);
397
435
  }
@@ -421,20 +459,21 @@ export function buildDefaultGates({
421
459
  ? { changedFileScope: formatChangedFileScope }
422
460
  : {}),
423
461
  },
462
+ ...qualityPreview.maintainability,
424
463
  ...(coverageCaptureActive
425
464
  ? [
426
465
  {
427
466
  name: 'coverage-capture',
428
467
  cmd: 'node',
429
468
  args: [...COVERAGE_CAPTURE_ARGS],
430
- hint: 'Coverage capture failed — `npm run test:coverage` exited non-zero. Fix failing tests or coverage-threshold breaches, then re-run close.',
469
+ hint: COVERAGE_CAPTURE_HINT,
431
470
  ...(captureSkipPredicted
432
471
  ? { skip: { reason: 'incremental-no-crap-changes' } }
433
472
  : {}),
434
473
  },
435
474
  ]
436
475
  : []),
437
- ...buildQualityPreviewGateEntry({ coverageCaptureActive, baseBranch }),
476
+ ...qualityPreview.crap,
438
477
  ...buildBaselinesGateEntries({
439
478
  decision: baselinesDecision,
440
479
  kinds: baselineKinds,
@@ -459,6 +498,7 @@ const INDEPENDENT_GATE_NAMES = new Set([
459
498
  'format',
460
499
  'typecheck',
461
500
  BASELINES_GATE_NAMES.independent,
501
+ QUALITY_PREVIEW_GATE_NAMES.maintainability,
462
502
  ]);
463
503
 
464
504
  /**
@@ -4,13 +4,11 @@ import { spawn } from 'node:child_process';
4
4
 
5
5
  import {
6
6
  LOCK_WAIT_EXPIRED_EXIT_CODE,
7
+ resolveFullSuiteLockBudget,
7
8
  withFullSuiteLockAsync,
8
9
  } from '../full-suite-lock.js';
9
- import {
10
- groupSpawnOptions,
11
- superviseGroup,
12
- TIMEOUT_EXIT_CODE,
13
- } from '../process-group.js';
10
+ import { groupSpawnOptions, TIMEOUT_EXIT_CODE } from '../process-group.js';
11
+ import { gateSupervision } from '../supervised-suite.js';
14
12
 
15
13
  /**
16
14
  * Emit each line with `prefix`; the unterminated tail flushes on `end`.
@@ -80,31 +78,35 @@ function isBiomeNoFilesProcessed(output) {
80
78
  *
81
79
  * `fullSuiteLock` serializes the spawn behind the host lock (async, so
82
80
  * sibling gates on this event loop are not stalled). An expired wait spawns
83
- * anyway unless `deferOnLockExpiry`, which returns
84
- * `LOCK_WAIT_EXPIRED_EXIT_CODE` so close ends `pending`. Each child leads its
85
- * own process group so a timeout, abort or parent signal kills its workers too.
81
+ * nothing and returns `LOCK_WAIT_EXPIRED_EXIT_CODE`, so close ends
82
+ * `pending`. A full-suite gate's timeout is armed at spawn, re-armed on the
83
+ * suite-ready handshake, and its lock wait, host wait and test run are logged
84
+ * as three figures. Each child leads its own process group so a timeout,
85
+ * abort or parent signal kills its workers too.
86
86
  *
87
87
  * @param {string} cmd
88
88
  * @param {string[]} args
89
- * @param {{ cwd: string, signal?: AbortSignal, gateName?: string, log?: (m: string) => void, env?: Record<string, string>, tolerateNoFilesProcessed?: boolean, fullSuiteLock?: boolean, deferOnLockExpiry?: boolean, timeoutMs?: number, lockOptions?: object, skipIfSatisfied?: () => {status: number}|undefined }} opts
89
+ * @param {{ cwd: string, signal?: AbortSignal, gateName?: string, log?: (m: string) => void, env?: Record<string, string>, tolerateNoFilesProcessed?: boolean, fullSuiteLock?: boolean, timeoutMs?: number, lockOptions?: object, skipIfSatisfied?: () => {status: number}|undefined }} opts
90
90
  * @returns {Promise<{ status: number }>}
91
91
  */
92
92
  export function defaultGateRunner(cmd, args, opts = {}) {
93
93
  if (!opts.fullSuiteLock) return spawnGate(cmd, args, opts);
94
94
  // `skipIfSatisfied` re-probes after the wait: evidence another suite
95
95
  // deposited meanwhile is returned instead of spawning.
96
- return withFullSuiteLockAsync(gateLockOptions(opts), () =>
97
- spawnGate(cmd, args, opts),
96
+ return withFullSuiteLockAsync(gateLockOptions(opts), (lock) =>
97
+ spawnGate(cmd, args, opts, lock),
98
98
  );
99
99
  }
100
100
 
101
+ /** The wait is bounded by the same kill bound that bounds the holder. */
101
102
  function gateLockOptions(opts) {
102
103
  return {
104
+ ...resolveFullSuiteLockBudget(opts.timeoutMs),
103
105
  cwd: opts.cwd,
104
106
  log: opts.log,
105
107
  skipIfSatisfied: opts.skipIfSatisfied,
106
- onWaitExpired: opts.deferOnLockExpiry ? deferredGateStatus : undefined,
107
- ...opts.lockOptions, // test seam only
108
+ onWaitExpired: deferredGateStatus,
109
+ ...opts.lockOptions, // `rerunCommand`, plus test seams
108
110
  };
109
111
  }
110
112
 
@@ -116,20 +118,16 @@ function deferredGateStatus() {
116
118
  * @param {string} cmd
117
119
  * @param {string[]} args
118
120
  * @param {Parameters<typeof defaultGateRunner>[2]} opts
121
+ * @param {{ lockWaitMs?: number }} [lock] How long the full-suite lock queued it.
119
122
  * @returns {Promise<{ status: number }>}
120
123
  */
121
- function spawnGate(cmd, args, opts) {
122
- const child = spawnGateChild(cmd, args, opts);
124
+ function spawnGate(cmd, args, opts, lock) {
125
+ const supervision = gateSupervision(opts, lock);
126
+ const child = spawnGateChild(cmd, args, opts.cwd, supervision.env);
123
127
  const output = gateOutput(opts);
124
128
  pipePrefixed(child.stdout, output.prefix, output.tap);
125
129
  pipePrefixed(child.stderr, output.prefix, output.tap);
126
- // A bare suite has nothing to clean up, so it gets SIGKILL; other gates
127
- // (a capture holding the lock) get SIGTERM to release and kill their suite.
128
- const supervisor = superviseGroup(child, {
129
- timeoutMs: opts.timeoutMs,
130
- abortSignal: opts.signal,
131
- signalOnParentSignal: opts.fullSuiteLock ? 'SIGKILL' : 'SIGTERM',
132
- });
130
+ const supervisor = supervision.supervise(child, output);
133
131
  return new Promise((resolve) => {
134
132
  // 'close', not 'exit': only 'close' waits for both pipes to drain.
135
133
  child.on('close', (code, sig) => {
@@ -148,9 +146,10 @@ function spawnGate(cmd, args, opts) {
148
146
  /**
149
147
  * @param {string} cmd
150
148
  * @param {string[]} args
151
- * @param {{ cwd: string, env?: Record<string, string> }} opts
149
+ * @param {string} cwd
150
+ * @param {Record<string, string>} [env]
152
151
  */
153
- function spawnGateChild(cmd, args, { cwd, env }) {
152
+ function spawnGateChild(cmd, args, cwd, env) {
154
153
  return spawn(cmd, args, {
155
154
  cwd,
156
155
  shell: process.platform === 'win32',