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,15 +2,15 @@
2
2
  /**
3
3
  * Ensures `coverage/coverage-final.json` is fresh before the CRAP gate: skip
4
4
  * when the gate is off, no changed file is under `crap.targetDirs`, or the
5
- * content-digest stamp matches; otherwise run `test:coverage` behind the
6
- * host-level full-suite lock and stamp on success.
5
+ * content-digest stamp matches; otherwise run `test:coverage` (or, under
6
+ * `coverage.captureScope: "affected"`, the consumer's `test:coverage:affected`)
7
+ * behind the host-level full-suite lock and stamp on success.
7
8
  *
8
9
  * `--require-credited` must stay an argument, never a config read: as policy
9
10
  * it also refused the depositing run, leaving no path that could deposit.
10
11
  *
11
12
  * Exit codes: 0 fresh/skipped/captured; 1 capture failed or refused (callers
12
- * MUST surface it); 75 lock wait expired and deferred (only under
13
- * `MANDREL_FULL_SUITE_LOCK_ON_EXPIRY=defer`); 124 suite timed out.
13
+ * MUST surface it); 75 lock wait expired, nothing spawned; 124 suite timed out.
14
14
  */
15
15
  import { getChangedFiles } from './lib/changed-files.js';
16
16
  import { isDirectInvocation } from './lib/cli-utils.js';
@@ -23,13 +23,13 @@ import {
23
23
  runCapture,
24
24
  writeCaptureStamp,
25
25
  } from './lib/coverage-capture.js';
26
+ import { tryScopedCapture } from './lib/coverage-capture-affected.js';
26
27
  import { runFullScopeCapture } from './lib/coverage-capture-fullscope.js';
27
- import { tryIncrementalCapture } from './lib/coverage-capture-incremental.js';
28
28
  import { handleCoverageCaptureHelp } from './lib/coverage-capture-usage.js';
29
29
  import { lockedCapture } from './lib/full-suite-lock.js';
30
-
31
30
  import { Logger } from './lib/Logger.js';
32
31
  import { hasNpmScript, readPackageScripts } from './lib/npm-scripts.js';
32
+ import { formatSuiteTimings } from './lib/supervised-suite.js';
33
33
 
34
34
  /**
35
35
  * A `null` ref defers the fallback to `resolveChangedFilesRef`.
@@ -67,6 +67,7 @@ export function parseArgs(argv) {
67
67
  * writeCaptureStampImpl?: typeof writeCaptureStamp,
68
68
  * filterFilesUnderTargetsImpl?: typeof filterFilesUnderTargets,
69
69
  * logger?: { info: Function, warn: Function, error: Function },
70
+ * lockOptions?: object,
70
71
  * }} [deps]
71
72
  * @returns {Promise<number>}
72
73
  */
@@ -83,6 +84,7 @@ export async function runCoverageCapture(argv = process.argv, deps = {}) {
83
84
  writeCaptureStampImpl = writeCaptureStamp,
84
85
  filterFilesUnderTargetsImpl = filterFilesUnderTargets,
85
86
  logger = Logger,
87
+ lockOptions = {},
86
88
  } = deps;
87
89
  const args = parseArgs(argv);
88
90
  const config = resolveConfigImpl({ cwd: args.cwd });
@@ -105,13 +107,65 @@ export async function runCoverageCapture(argv = process.argv, deps = {}) {
105
107
 
106
108
  // Outermost first: the credit probe announces or refuses the run, and only
107
109
  // a surviving run reaches the host-level full-suite lock.
108
- const capture = creditedCapture(lockedCapture(runCaptureImpl, config), {
110
+ const locked = lockedCapture(runCaptureImpl, config, process.env, {
111
+ rerunCommand: rerunCommandFor(argv),
112
+ ...lockOptions,
113
+ });
114
+ const credited = creditedCapture(locked, {
109
115
  requireCredited: args.requireCredited,
110
116
  logger,
111
117
  });
118
+ let timings = null;
119
+ const capture = (captureOpts) =>
120
+ credited({
121
+ ...captureOpts,
122
+ onTimings: (t) => {
123
+ timings = t;
124
+ },
125
+ });
126
+ const code = await runCaptureScopes({
127
+ crap,
128
+ coverage,
129
+ args,
130
+ capture,
131
+ deps: {
132
+ getChangedFilesImpl,
133
+ isCoverageFreshImpl,
134
+ computeContentDigestImpl,
135
+ writeCaptureStampImpl,
136
+ filterFilesUnderTargetsImpl,
137
+ readPackageScriptsImpl,
138
+ hasNpmScriptImpl,
139
+ logger,
140
+ },
141
+ });
142
+ if (timings) logger.info(`[coverage-capture] ${formatSuiteTimings(timings)}`);
143
+ return code;
144
+ }
145
+
146
+ /** @param {string[]} argv */
147
+ function rerunCommandFor(argv) {
148
+ return ['node', ...argv.slice(1)].join(' ');
149
+ }
150
+
151
+ /**
152
+ * @param {{ crap: object, coverage: object, args: object, capture: Function, deps: object }} opts
153
+ * @returns {Promise<number>}
154
+ */
155
+ async function runCaptureScopes({ crap, coverage, args, capture, deps }) {
156
+ const {
157
+ getChangedFilesImpl,
158
+ isCoverageFreshImpl,
159
+ computeContentDigestImpl,
160
+ writeCaptureStampImpl,
161
+ filterFilesUnderTargetsImpl,
162
+ readPackageScriptsImpl,
163
+ hasNpmScriptImpl,
164
+ logger,
165
+ } = deps;
112
166
 
113
167
  // Shared so a new seam cannot reach one capture path and miss the other.
114
- // An incremental `null` means not applicable: fall through to full scope.
168
+ // A scoped `null` means not applicable: fall through to full scope.
115
169
  const shared = {
116
170
  crap,
117
171
  coverage,
@@ -124,9 +178,11 @@ export async function runCoverageCapture(argv = process.argv, deps = {}) {
124
178
  logger,
125
179
  };
126
180
 
127
- const incrementalResult = await tryIncrementalCapture({
181
+ const incrementalResult = await tryScopedCapture({
128
182
  ...shared,
129
183
  filterFilesUnderTargetsImpl,
184
+ readPackageScriptsImpl,
185
+ hasNpmScriptImpl,
130
186
  });
131
187
  if (incrementalResult !== null) return incrementalResult;
132
188
 
@@ -8,10 +8,11 @@
8
8
  * any still-stuck entries by enumerating the processes holding handles
9
9
  * inside the worktree path and terminating them.
10
10
  *
11
- * Invoked by `/mandrel-deliver` and `/mandrel-plan`
12
- * (via `drainPendingCleanupAtBoot` → `worktree-sweep.js`), and
13
- * `story-close` so the pending-cleanup ledger drains automatically
14
- * across the sprint lifecycle. Operators can also run it standalone:
11
+ * The same drain runs automatically at workflow boot: `runBootSweep`
12
+ * (`boot-sweep.js`) calls `sweepStaleStoryWorktrees`
13
+ * (`lib/orchestration/plan-runner/worktree-sweep.js`), which force-drains
14
+ * the ledger before reaping closed-Story worktrees. Operators can also run
15
+ * it standalone:
15
16
  *
16
17
  * node .agents/scripts/drain-pending-cleanup.js # full drain + escalate
17
18
  * node .agents/scripts/drain-pending-cleanup.js --no-escalate # passive drain only
@@ -5,14 +5,25 @@
5
5
  * passed for the current HEAD and tree, and recording a pass for the next
6
6
  * caller. `--standalone` is required: it keys evidence by Story id, the same
7
7
  * keyspace close consults, so worker-side verify[] runs credit the close.
8
+ * `--gate test` takes the host full-suite lock.
8
9
  */
9
10
 
10
11
  import { spawnSync } from 'node:child_process';
11
12
  import { parseArgs } from 'node:util';
12
13
  import { runAsCli } from './lib/cli-utils.js';
14
+ import { getQuality, resolveConfig } from './lib/config-resolver.js';
15
+ import {
16
+ fullSuiteLockPolicy,
17
+ LOCK_WAIT_EXPIRED_EXIT_CODE,
18
+ withFullSuiteLockAsync,
19
+ } from './lib/full-suite-lock.js';
13
20
  import { gitSpawn } from './lib/git-utils.js';
14
21
  import { Logger } from './lib/Logger.js';
15
22
  import { PROJECT_ROOT } from './lib/project-root.js';
23
+ import {
24
+ formatSuiteTimings,
25
+ runSupervisedSuite,
26
+ } from './lib/supervised-suite.js';
16
27
  import {
17
28
  hashCommandConfig,
18
29
  recordPass,
@@ -20,6 +31,8 @@ import {
20
31
  treeFingerprint,
21
32
  } from './lib/validation-evidence.js';
22
33
 
34
+ const FULL_SUITE_GATE = 'test';
35
+
23
36
  export function splitOnDashDash(argv) {
24
37
  const idx = argv.indexOf('--');
25
38
  if (idx === -1) return { wrapperArgs: argv, runnerArgs: [] };
@@ -90,6 +103,7 @@ function resolveEvidenceKeys({ spawnCwd, gitSpawnFn, useEvidence }) {
90
103
  * @param {Function} [deps.shouldSkipFn]
91
104
  * @param {Function} [deps.recordPassFn]
92
105
  * @param {object} [deps.logger]
106
+ * @param {Function} [deps.runSuiteFn]
93
107
  * @returns {{ status: number, skipped: boolean }}
94
108
  */
95
109
  export async function runEvidenceGate(params, deps = {}) {
@@ -99,6 +113,7 @@ export async function runEvidenceGate(params, deps = {}) {
99
113
  shouldSkipFn = shouldSkip,
100
114
  recordPassFn = recordPass,
101
115
  logger = Logger,
116
+ runSuiteFn = runLockedSuite,
102
117
  } = deps;
103
118
  const {
104
119
  scopeId,
@@ -159,17 +174,18 @@ export async function runEvidenceGate(params, deps = {}) {
159
174
  logger.info(
160
175
  `[evidence-gate] ▶ ${gate} → ${cmd} ${cmdArgs.join(' ')} (cwd=${spawnCwd})`,
161
176
  );
162
- const result = spawnFn(cmd, cmdArgs, {
163
- cwd: spawnCwd,
164
- stdio: 'inherit',
165
- shell: process.platform === 'win32',
177
+ const status = await runGateCommand({
178
+ gate,
179
+ cmd,
180
+ cmdArgs,
181
+ spawnCwd,
182
+ logger,
183
+ spawnFn,
184
+ runSuiteFn,
166
185
  });
167
- const status = result.status ?? 1;
168
186
  if (status !== 0) {
169
187
  process.exitCode = status;
170
- logger.error(
171
- `[evidence-gate] ✖ ${gate} failed (exit ${status}) in ${spawnCwd}`,
172
- );
188
+ reportGateExit({ gate, status, spawnCwd, logger });
173
189
  return { status, skipped: false };
174
190
  }
175
191
 
@@ -197,6 +213,88 @@ export async function runEvidenceGate(params, deps = {}) {
197
213
  return { status: 0, skipped: false };
198
214
  }
199
215
 
216
+ /**
217
+ * @param {{ cmd: string, args: string[], cwd: string, log: (m: string) => void }} run
218
+ * @param {{ config?: object|null, env?: object, lockOptions?: object, runSuiteImpl?: typeof runSupervisedSuite }} [seams]
219
+ * @returns {Promise<number>} 75 when the lock wait expired.
220
+ */
221
+ export function runLockedSuite(
222
+ { cmd, args, cwd, log },
223
+ {
224
+ config = loadConfig(cwd),
225
+ env = process.env,
226
+ lockOptions = {},
227
+ runSuiteImpl = runSupervisedSuite,
228
+ } = {},
229
+ ) {
230
+ const timeoutMs = getQuality(config).coverage?.timeoutMs;
231
+ return withFullSuiteLockAsync(
232
+ {
233
+ ...fullSuiteLockPolicy(config, env),
234
+ cwd,
235
+ log,
236
+ rerunCommand: ['node', ...process.argv.slice(1)].join(' '),
237
+ ...lockOptions,
238
+ },
239
+ ({ lockWaitMs }) =>
240
+ runSuiteImpl({
241
+ cmd,
242
+ args,
243
+ cwd,
244
+ timeoutMs,
245
+ lockWaitMs,
246
+ onTimings: (t) => log(`[evidence-gate] ${formatSuiteTimings(t)}`),
247
+ onTimeout: () =>
248
+ log(
249
+ `[evidence-gate] ⏱ ${cmd} ${args.join(' ')} exceeded ${timeoutMs}ms — killed its process group.`,
250
+ ),
251
+ }),
252
+ );
253
+ }
254
+
255
+ /** The `test` gate runs locked and supervised; any other gate plainly. */
256
+ async function runGateCommand({
257
+ gate,
258
+ cmd,
259
+ cmdArgs,
260
+ spawnCwd,
261
+ logger,
262
+ spawnFn,
263
+ runSuiteFn,
264
+ }) {
265
+ if (gate === FULL_SUITE_GATE) {
266
+ const log = (m) => logger.info(m);
267
+ return await runSuiteFn({ cmd, args: cmdArgs, cwd: spawnCwd, log });
268
+ }
269
+ const result = spawnFn(cmd, cmdArgs, {
270
+ cwd: spawnCwd,
271
+ stdio: 'inherit',
272
+ shell: process.platform === 'win32',
273
+ });
274
+ return result.status ?? 1;
275
+ }
276
+
277
+ function reportGateExit({ gate, status, spawnCwd, logger }) {
278
+ if (status === LOCK_WAIT_EXPIRED_EXIT_CODE) {
279
+ logger.info(
280
+ `[evidence-gate] ⏸ ${gate} deferred (exit ${status}) — another full suite still holds the host lock, so nothing ran and no evidence was recorded.`,
281
+ );
282
+ return;
283
+ }
284
+ logger.error(
285
+ `[evidence-gate] ✖ ${gate} failed (exit ${status}) in ${spawnCwd}`,
286
+ );
287
+ }
288
+
289
+ /** An unloadable config falls back to the defaults. */
290
+ function loadConfig(cwd) {
291
+ try {
292
+ return resolveConfig({ cwd });
293
+ } catch {
294
+ return null;
295
+ }
296
+ }
297
+
200
298
  async function main() {
201
299
  const { wrapperArgs, runnerArgs } = splitOnDashDash(process.argv.slice(2));
202
300
  const args = parseWrapperArgs(wrapperArgs);
@@ -0,0 +1,60 @@
1
+ /** Wires `resolveCoverageRefreshScope` to the on-disk artifact and c8 scope. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import {
5
+ buildScopePredicate,
6
+ readArtifactCaptureScope,
7
+ readCoverageFinal,
8
+ resolveCoverageRefreshScope,
9
+ scoreCoverageFinal,
10
+ } from '../coverage-baseline.js';
11
+ import { deriveScopeFromDiff } from './refresh-service.js';
12
+
13
+ /**
14
+ * `refreshBaseline` scope options for `update-coverage-baseline.js`.
15
+ *
16
+ * @param {string} cwd
17
+ * @param {{
18
+ * fullScope: boolean,
19
+ * diffScopeRef: string | null,
20
+ * loadScope: (cwd: string) => { include?: string[], exclude?: string[] },
21
+ * readCaptureScope?: typeof readArtifactCaptureScope,
22
+ * readCoverage?: typeof readCoverageFinal,
23
+ * existsSync?: typeof fs.existsSync,
24
+ * deriveDiff?: typeof deriveScopeFromDiff,
25
+ * }} opts
26
+ */
27
+ export function resolveUpdaterRefreshScope(
28
+ cwd,
29
+ {
30
+ fullScope,
31
+ diffScopeRef,
32
+ loadScope,
33
+ readCaptureScope = readArtifactCaptureScope,
34
+ readCoverage = readCoverageFinal,
35
+ existsSync = fs.existsSync,
36
+ deriveDiff = deriveScopeFromDiff,
37
+ },
38
+ ) {
39
+ const c8 = () => {
40
+ const config = loadScope(cwd);
41
+ return buildScopePredicate({
42
+ include: config.include ?? [],
43
+ exclude: config.exclude ?? [],
44
+ });
45
+ };
46
+ return resolveCoverageRefreshScope({
47
+ cwd,
48
+ fullScope,
49
+ diffScopeRef,
50
+ readCaptureScope,
51
+ listMeasured: () =>
52
+ Object.keys(
53
+ scoreCoverageFinal({ raw: readCoverage(cwd), cwd, scope: c8() }),
54
+ ),
55
+ inCoverageScope: (file) =>
56
+ c8()(file) && existsSync(path.resolve(cwd, file)),
57
+ deriveDiffFiles: (baseRef) =>
58
+ deriveDiff({ baseRef, headRef: 'HEAD', predicate: () => true, cwd }),
59
+ });
60
+ }
@@ -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');