mandrel 2.41.0 → 2.43.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 (39) hide show
  1. package/.agents/agents/story-worker.md +24 -14
  2. package/.agents/docs/agentrc-reference.json +11 -2
  3. package/.agents/docs/configuration.md +9 -3
  4. package/.agents/docs/workflows.md +1 -1
  5. package/.agents/schemas/agentrc.schema.json +37 -3
  6. package/.agents/schemas/validation-evidence.schema.json +3 -1
  7. package/.agents/scripts/acceptance-eval.js +68 -3
  8. package/.agents/scripts/coverage-capture.js +25 -8
  9. package/.agents/scripts/lib/baselines/crap-preview-incremental.js +7 -2
  10. package/.agents/scripts/lib/baselines/git-base.js +74 -38
  11. package/.agents/scripts/lib/close-validation/gates.js +153 -25
  12. package/.agents/scripts/lib/close-validation/process.js +30 -1
  13. package/.agents/scripts/lib/close-validation/runner.js +5 -0
  14. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +33 -12
  15. package/.agents/scripts/lib/config/quality.js +36 -21
  16. package/.agents/scripts/lib/config-settings-schema-delivery.js +6 -0
  17. package/.agents/scripts/lib/config-settings-schema.js +29 -1
  18. package/.agents/scripts/lib/coverage-capture-incremental.js +12 -6
  19. package/.agents/scripts/lib/crap-baseline-join.js +11 -7
  20. package/.agents/scripts/lib/full-suite-lock.js +311 -0
  21. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  22. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +11 -104
  23. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +320 -0
  24. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  25. package/.agents/scripts/lib/orchestration/plan-context.js +4 -0
  26. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +9 -1
  27. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +159 -55
  28. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +83 -4
  29. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +39 -7
  30. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +70 -18
  31. package/.agents/scripts/lib/orchestration/verify-credit.js +207 -0
  32. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +24 -0
  33. package/.agents/workflows/helpers/acceptance-self-eval.md +12 -0
  34. package/.agents/workflows/helpers/deliver-digest.md +31 -10
  35. package/.agents/workflows/helpers/deliver-story-reference.md +50 -30
  36. package/.agents/workflows/helpers/deliver-story.md +23 -21
  37. package/.agents/workflows/memory-consolidate.md +18 -6
  38. package/docs/CHANGELOG.md +25 -0
  39. package/package.json +1 -1
@@ -117,28 +117,74 @@ function isCrapGateEnabled(config) {
117
117
  */
118
118
  function buildTestGateEntry(coverageCaptureActive) {
119
119
  if (coverageCaptureActive) return [];
120
- return [{ name: 'test', cmd: 'npm', args: ['test'] }];
120
+ // Story #5173 `fullSuiteLock` marks the one gate here that spawns a whole
121
+ // suite, so `defaultGateRunner` serializes it behind the host lock. It is
122
+ // set on this entry alone precisely because the two full-suite gates are
123
+ // mutually exclusive: when `coverage-capture` is registered instead, the
124
+ // lock is taken one level down, inside `runCapture`.
125
+ return [{ name: 'test', cmd: 'npm', args: ['test'], fullSuiteLock: true }];
121
126
  }
122
127
 
123
128
  const CHECK_BASELINES_HINT =
124
129
  'Unified baselines gate breached. Inspect the JSON report (`node .agents/scripts/check-baselines.js`) to see which kind/component/axis fell below floor; remediate the underlying file(s) or — when the regression is intentional — refresh the relevant baseline through its per-kind update script and commit with a `baseline-refresh:` tagged subject.';
125
130
 
131
+ /**
132
+ * The names the unified baselines gate can register under (Story #5172).
133
+ *
134
+ * `single` is the unsplit entry — the historical name, and the fail-closed
135
+ * fallback used whenever the enabled-kind set cannot be resolved into two
136
+ * buckets. `independent` and `coverage` are the split pair: the first reads no
137
+ * coverage artifact and therefore fails alongside `lint` / `format` /
138
+ * `typecheck` in the parallel partition, the second consumes the artifact
139
+ * `coverage-capture` writes and therefore stays serial behind it.
140
+ *
141
+ * Every name here MUST also be a member of the `gateName` enum in
142
+ * `.agents/schemas/validation-evidence.schema.json` — the close pipeline keys
143
+ * per-gate evidence on it. `tests/close-validation-gates-enum.test.js` pins
144
+ * that ⊆ invariant.
145
+ */
146
+ export const BASELINES_GATE_NAMES = Object.freeze({
147
+ single: 'check-baselines',
148
+ independent: 'check-baselines-independent',
149
+ coverage: 'check-baselines-coverage',
150
+ });
151
+
152
+ /**
153
+ * The baseline kinds whose evaluation reads the coverage artifact written by
154
+ * the `coverage-capture` gate (`coverage` scores it directly; `crap` divides
155
+ * complexity by it). They are the only kinds that have to wait for the
156
+ * capture — every other kind scores the source tree and can run as early as
157
+ * the cheapest gates do.
158
+ */
159
+ const COVERAGE_CONSUMING_KINDS = new Set(['coverage', 'crap']);
160
+
126
161
  /**
127
162
  * Baseline kinds the resolved config enables for the unified
128
163
  * `check-baselines` gate. Mirrors `selectEnabledGates` in the check-baselines
129
164
  * pipeline (a kind runs when its `gates.<kind>` block is present and not
130
165
  * explicitly disabled) so the registration probe's view of "what will run"
131
- * matches the gate's own view exactly.
166
+ * matches the gate's own view exactly — and so the Story #5172 partition is
167
+ * derived from the pipeline's own view of what runs rather than a hardcoded
168
+ * kind list that a consumer's config could silently contradict.
169
+ *
170
+ * Returns `null` when that view cannot be resolved at all (a config object
171
+ * whose `delivery.quality` access throws). Callers MUST read `null` as
172
+ * "unknown" and fall back to the single unsplit gate: a partition that cannot
173
+ * be computed must never silently drop enforcement.
132
174
  *
133
175
  * @param {object|undefined|null} config canonical resolved config
134
- * @returns {string[]}
176
+ * @returns {string[]|null}
135
177
  */
136
178
  function enabledBaselineKinds(config) {
137
- const gates = getQuality(config)?.gates ?? {};
138
- return KNOWN_KINDS.filter((kind) => {
139
- const block = gates[kind];
140
- return block && typeof block === 'object' && block.enabled !== false;
141
- });
179
+ try {
180
+ const gates = getQuality(config)?.gates ?? {};
181
+ return KNOWN_KINDS.filter((kind) => {
182
+ const block = gates[kind];
183
+ return block && typeof block === 'object' && block.enabled !== false;
184
+ });
185
+ } catch {
186
+ return null;
187
+ }
142
188
  }
143
189
 
144
190
  /**
@@ -182,13 +228,22 @@ function toKindSet(presentBaselines) {
182
228
  * (`requireBaselines: true`) but absent; keep the gate registered so it
183
229
  * fails, with a preflight hint naming the fix.
184
230
  *
185
- * @param {{ config?: object, cwd?: string, presentBaselines?: string[]|Set<string> }} opts
186
- * `presentBaselines` injects the set of kinds whose baseline artifact
187
- * exists (tests), short-circuiting the on-disk probe.
231
+ * @param {{ config?: object, cwd?: string, enabledKinds?: string[]|null, presentBaselines?: string[]|Set<string> }} opts
232
+ * `enabledKinds` is `enabledBaselineKinds(config)` computed once by the
233
+ * caller (so the probe and the partition below read the same view).
234
+ * A `null` — the unresolvable set — reads as "no enabled kinds", which is
235
+ * the fail-closed path: the gate stays registered under its single
236
+ * historical name. `presentBaselines` injects the set of kinds whose
237
+ * baseline artifact exists (tests), short-circuiting the on-disk probe.
188
238
  * @returns {{ register: boolean, reason?: string, hint?: string }}
189
239
  */
190
- function probeBaselinesGate({ config, cwd, presentBaselines } = {}) {
191
- const enabled = enabledBaselineKinds(config);
240
+ function probeBaselinesGate({
241
+ config,
242
+ cwd,
243
+ enabledKinds,
244
+ presentBaselines,
245
+ } = {}) {
246
+ const enabled = enabledKinds ?? [];
192
247
  if (enabled.length === 0) {
193
248
  // No enabled baseline kinds → `check-baselines.js` self-skips every kind
194
249
  // and exits clean (an empty PASS). There is no deterministic-failure risk
@@ -220,6 +275,66 @@ function probeBaselinesGate({ config, cwd, presentBaselines } = {}) {
220
275
  };
221
276
  }
222
277
 
278
+ /**
279
+ * Build the `check-baselines` gate entries for this run (Story #5172).
280
+ *
281
+ * One registration decision, one `BASELINE_REF` overlay, one remediation
282
+ * hint — fanned out across however many entries the enabled-kind set splits
283
+ * into. Keeping the fan-out here is what makes the #3890 (`BASELINE_REF`)
284
+ * and #4495 (`probeBaselinesGate`) invariants structurally impossible to
285
+ * apply to one entry and forget on the other.
286
+ *
287
+ * Three shapes:
288
+ * - decision says skip → no entries at all (#4495's greenfield skip).
289
+ * - `kinds` is null (unresolvable) or empty → ONE entry under the single
290
+ * historical name with no `--gate` filter, in its historical serial
291
+ * position. Fail closed: a partition that cannot be computed must never
292
+ * silently drop enforcement, and an empty set means the gate self-skips
293
+ * every kind and exits a clean empty PASS exactly as it did pre-split.
294
+ * - otherwise → the split pair, each pinned to its own `--gate` list.
295
+ * Neither bucket is ever registered with an empty kind set, so a consumer
296
+ * running only coverage-consuming kinds gets no parallel entry and one
297
+ * running none of them gets no serial entry.
298
+ *
299
+ * The independent entry is emitted first so a reader of the gate list sees
300
+ * the order the runner actually walks; `partitionGates` is what routes it
301
+ * into the parallel phase, and the coverage entry keeps its declared
302
+ * position after `coverage-capture`.
303
+ *
304
+ * @param {{ decision: { register: boolean, hint?: string }, kinds: string[]|null, env: { BASELINE_REF: string }|null }} args
305
+ * @returns {Gate[]}
306
+ */
307
+ function buildBaselinesGateEntries({ decision, kinds, env }) {
308
+ if (!decision.register) return [];
309
+ const entry = (name, gateKinds) => ({
310
+ name,
311
+ cmd: 'node',
312
+ args: [
313
+ '.agents/scripts/check-baselines.js',
314
+ ...(gateKinds ? ['--gate', gateKinds.join(',')] : []),
315
+ '--format',
316
+ 'text',
317
+ ],
318
+ hint: decision.hint ?? CHECK_BASELINES_HINT,
319
+ ...(env ? { env } : {}),
320
+ });
321
+ if (!Array.isArray(kinds) || kinds.length === 0) {
322
+ return [entry(BASELINES_GATE_NAMES.single, null)];
323
+ }
324
+ const independentKinds = kinds.filter(
325
+ (k) => !COVERAGE_CONSUMING_KINDS.has(k),
326
+ );
327
+ const coverageKinds = kinds.filter((k) => COVERAGE_CONSUMING_KINDS.has(k));
328
+ return [
329
+ ...(independentKinds.length > 0
330
+ ? [entry(BASELINES_GATE_NAMES.independent, independentKinds)]
331
+ : []),
332
+ ...(coverageKinds.length > 0
333
+ ? [entry(BASELINES_GATE_NAMES.coverage, coverageKinds)]
334
+ : []),
335
+ ];
336
+ }
337
+
223
338
  /**
224
339
  * Build the canonical close-validation gate list.
225
340
  *
@@ -308,9 +423,11 @@ export function buildDefaultGates({
308
423
  ? buildChangedFileScope(baseBranch)
309
424
  : null;
310
425
  const baselinesGateEnv = buildBaselinesGateEnv(baseBranch);
426
+ const baselineKinds = enabledBaselineKinds(config);
311
427
  const baselinesDecision = probeBaselinesGate({
312
428
  config,
313
429
  cwd,
430
+ enabledKinds: baselineKinds,
314
431
  presentBaselines,
315
432
  });
316
433
  if (!baselinesDecision.register && baselinesDecision.reason) {
@@ -362,17 +479,19 @@ export function buildDefaultGates({
362
479
  // gate fails deterministically on first try reading a non-existent
363
480
  // `baselines/<kind>.json` (`probeBaselinesGate`). When required-by-config
364
481
  // but absent, it stays registered with a preflight hint naming the fix.
365
- ...(baselinesDecision.register
366
- ? [
367
- {
368
- name: 'check-baselines',
369
- cmd: 'node',
370
- args: ['.agents/scripts/check-baselines.js', '--format', 'text'],
371
- hint: baselinesDecision.hint ?? CHECK_BASELINES_HINT,
372
- ...(baselinesGateEnv ? { env: baselinesGateEnv } : {}),
373
- },
374
- ]
375
- : []),
482
+ //
483
+ // Story #5172: the gate registers as up to TWO entries. The kinds that
484
+ // read no coverage artifact run in the parallel independent partition so
485
+ // a baseline breach fails beside `lint` / `format` / `typecheck` instead
486
+ // of minutes later behind `coverage-capture`; the coverage-consuming
487
+ // kinds keep the serial slot after it. `buildBaselinesGateEntries` owns
488
+ // that fan-out so both entries inherit ONE registration decision, ONE
489
+ // `BASELINE_REF` overlay and ONE hint.
490
+ ...buildBaselinesGateEntries({
491
+ decision: baselinesDecision,
492
+ kinds: baselineKinds,
493
+ env: baselinesGateEnv,
494
+ }),
376
495
  ];
377
496
  }
378
497
 
@@ -392,7 +511,16 @@ export const DEFAULT_GATES = buildDefaultGates();
392
511
  * state, no overlapping ports/sockets). Safe to run concurrently — see
393
512
  * `runCloseValidation` for the Promise.all + AbortController plumbing.
394
513
  */
395
- const INDEPENDENT_GATE_NAMES = new Set(['lint', 'format', 'typecheck']);
514
+ const INDEPENDENT_GATE_NAMES = new Set([
515
+ 'lint',
516
+ 'format',
517
+ 'typecheck',
518
+ // Story #5172 — the coverage-independent half of the baselines gate. It
519
+ // reads the committed `baselines/<kind>.json` files and scores the source
520
+ // tree in-process; it writes nothing and shares no port, so it satisfies
521
+ // the same read-only contract as the three gates above.
522
+ BASELINES_GATE_NAMES.independent,
523
+ ]);
396
524
 
397
525
  /**
398
526
  * Partition a gate list into the parallel-safe set and the order-sensitive
@@ -7,6 +7,8 @@
7
7
 
8
8
  import { spawn } from 'node:child_process';
9
9
 
10
+ import { withFullSuiteLockAsync } from '../full-suite-lock.js';
11
+
10
12
  /**
11
13
  * Pipe a child stream's output line-by-line through `emit`, prepending
12
14
  * `prefix` to each line. Tail bytes without a trailing newline flush on
@@ -141,12 +143,39 @@ function isBiomeNoFilesProcessed(output) {
141
143
  * because that exit means every config-included path was already excluded,
142
144
  * not that formatting drifted.
143
145
  *
146
+ * When `opts.fullSuiteLock` is set — the standalone `test` gate, the one gate
147
+ * here that runs a whole suite (Story #5173) — the spawn is serialized behind
148
+ * the host-level advisory lock so two concurrent closes on one checkout do not
149
+ * run two suites against the same cores. Best-effort: a wait that expires
150
+ * spawns anyway. The async wrapper is used rather than the synchronous one
151
+ * precisely because this runner drives sibling gates on the same event loop,
152
+ * which a blocking wait would stall.
153
+ *
144
154
  * @param {string} cmd
145
155
  * @param {string[]} args
146
- * @param {{ cwd: string, signal?: AbortSignal, gateName?: string, log?: (m: string) => void, env?: Record<string, string>, tolerateNoFilesProcessed?: boolean }} opts
156
+ * @param {{ cwd: string, signal?: AbortSignal, gateName?: string, log?: (m: string) => void, env?: Record<string, string>, tolerateNoFilesProcessed?: boolean, fullSuiteLock?: boolean }} opts
147
157
  * @returns {Promise<{ status: number }>}
148
158
  */
149
159
  export function defaultGateRunner(cmd, args, opts = {}) {
160
+ if (!opts.fullSuiteLock) return spawnGate(cmd, args, opts);
161
+ // `log` is passed through as-is: `withFullSuiteLockAsync` supplies its own
162
+ // no-op default, so a second fallback here would be an untestable branch.
163
+ return withFullSuiteLockAsync({ cwd: opts.cwd, log: opts.log }, () =>
164
+ spawnGate(cmd, args, opts),
165
+ );
166
+ }
167
+
168
+ /**
169
+ * The bare gate spawn — child process, prefixed drain, abort wiring, exit-code
170
+ * normalisation. Split from {@link defaultGateRunner} so the full-suite lock
171
+ * composes over one named unit.
172
+ *
173
+ * @param {string} cmd
174
+ * @param {string[]} args
175
+ * @param {Parameters<typeof defaultGateRunner>[2]} opts
176
+ * @returns {Promise<{ status: number }>}
177
+ */
178
+ function spawnGate(cmd, args, opts) {
150
179
  const { cwd, signal, gateName, log, env, tolerateNoFilesProcessed } = opts;
151
180
  const child = spawn(cmd, args, {
152
181
  cwd,
@@ -226,6 +226,11 @@ export async function runCloseValidation({
226
226
  log,
227
227
  signal,
228
228
  ...(gate.env ? { env: gate.env } : {}),
229
+ // Story #5173 — forwarded unconditionally (never a conditional spread
230
+ // like the two below): `defaultGateRunner` already treats a falsy value
231
+ // as "no lock", so a branch here would only add a decision point to the
232
+ // hottest function in this file.
233
+ fullSuiteLock: gate.fullSuiteLock,
229
234
  ...(gate.tolerateNoFilesProcessed
230
235
  ? { tolerateNoFilesProcessed: true }
231
236
  : {}),
@@ -1,32 +1,53 @@
1
1
  /* node:coverage ignore file -- AJV schema declaration (data-as-code) */
2
2
 
3
3
  /**
4
- * `delivery.quality.gates.crap.incrementalCoverage` — opt-in incremental
5
- * coverage-capture + CRAP-join scoping (Story #4981).
4
+ * `delivery.quality.gates.crap.incrementalCoverage` — the two independent
5
+ * full-suite economies (Story #4981, split by Story #5173).
6
6
  *
7
7
  * Split into its own module (rather than an inline property literal on
8
8
  * `CRAP_GATE`) so the schema addition lands as a new file, not a same-file
9
9
  * expansion of `crap.schema.js` — the file this module's sole export is
10
10
  * spread into.
11
11
  *
12
- * Default (key absent) preserves today's full-repo behaviour byte-for-byte.
13
- * When `enabled: true`, the changed-file set against `baseRef` (default: the
14
- * gate's own `--ref` / `main`) decides **whether** to capture — no changed
15
- * file under `crap.targetDirs` means no capture at all — and the CRAP join
16
- * treats a method in a file the diff did not touch as resolved by its
17
- * committed baseline row instead of requiring fresh coverage for it. It does
18
- * not narrow the capture run itself: a capture that does happen is the
19
- * ordinary full `npm run test:coverage` (Story #5065).
12
+ * The two switches are deliberately independent because they are not equally
13
+ * safe:
14
+ *
15
+ * - **`skipWhenUnchanged`** (default `true`) decides *whether* to capture:
16
+ * no changed file under `crap.targetDirs` versus `baseRef` means no
17
+ * capture at all. It is a pure saving the gates score exactly what they
18
+ * scored before, because nothing they score moved.
19
+ * - **`baselineJoin`** (default `false`) loosens gate semantics: the CRAP
20
+ * join resolves a method in an untouched file from its committed baseline
21
+ * row instead of requiring fresh coverage for it.
22
+ *
23
+ * Bundling them under one `enabled` switch is what forced the earlier default
24
+ * flip to be reverted. `enabled` survives as a deprecated alias that sets
25
+ * both.
26
+ *
27
+ * Neither switch narrows the capture run itself: a capture that does happen is
28
+ * the ordinary full `npm run test:coverage` (Story #5065).
20
29
  */
21
30
  export const INCREMENTAL_COVERAGE_SCHEMA = {
22
31
  type: 'object',
23
32
  description:
24
- 'Story #4981 opt-in incremental coverage-capture + CRAP-join scoping. Default (key absent) preserves today’s full-repo behaviour byte-for-byte. When `enabled: true`, the changed-file set against `baseRef` (default: the gate’s own `--ref` / `main`) decides WHETHER to capture — no changed file under `crap.targetDirs` means no capture at all — and the CRAP join treats a method in a file the diff did not touch as resolved by its committed baseline row instead of requiring fresh coverage for it. It does NOT narrow the capture run itself: a capture that does happen is the ordinary full `npm run test:coverage` (Story #5065).',
33
+ 'The two independent full-suite economies (Story #4981, split by Story #5173). `skipWhenUnchanged` (default true) decides WHETHER to capture — no changed file under `crap.targetDirs` versus `baseRef` means no capture at all — and is a pure saving. `baselineJoin` (default false) loosens gate semantics: the CRAP join resolves a method in an untouched file from its committed baseline row instead of requiring fresh coverage for it. Neither narrows the capture run itself: a capture that does happen is the ordinary full `npm run test:coverage` (Story #5065).',
25
34
  properties: {
35
+ skipWhenUnchanged: {
36
+ type: 'boolean',
37
+ description:
38
+ 'Skip the capture entirely when no changed file under `crap.targetDirs` versus `baseRef` was touched. The only measured saving, and gate-semantics-neutral. Defaults to true.',
39
+ default: true,
40
+ },
41
+ baselineJoin: {
42
+ type: 'boolean',
43
+ description:
44
+ 'Let the CRAP join resolve a method in a file the diff did not touch from its committed baseline row instead of requiring fresh coverage for it. A gate loosening, not a saving — defaults to false.',
45
+ default: false,
46
+ },
26
47
  enabled: {
27
48
  type: 'boolean',
28
49
  description:
29
- 'Master switch for the capture skip and the baseline-resolved CRAP join.',
50
+ 'DEPRECATED alias for setting both `skipWhenUnchanged` and `baselineJoin`. Prefer the two switches: they are not equally safe, and bundling them is why the earlier default flip was reverted. Either explicit switch overrides this alias.',
30
51
  },
31
52
  baseRef: {
32
53
  type: 'string',
@@ -72,22 +72,27 @@ const DEFAULT_MI_FLOORS = Object.freeze({
72
72
  });
73
73
 
74
74
  /**
75
- * Story #4981 — opt-in incremental coverage-capture + CRAP-join scoping.
76
- * Disabled by default: `coverage-capture.js` and the CRAP join keep their
77
- * pre-#4981 full-repo behaviour byte-for-byte until a consumer sets
78
- * `enabled: true`. `baseRef: null` means "use the caller's own ref
79
- * resolution" (the gate's `--ref` flag / `main`) rather than a second,
80
- * possibly-conflicting default.
75
+ * Story #4981 / #5065 / #5173 the two independent full-suite economies.
81
76
  *
82
- * Story #5065 what `enabled: true` actually buys, measured: the capture is
83
- * **skipped entirely** when no changed file lives under `crap.targetDirs`,
84
- * and the CRAP join resolves methods in untouched files from the committed
85
- * baseline row instead of requiring fresh coverage. It does **not** shorten
86
- * the capture run — when a capture does happen it is the ordinary full
87
- * `npm run test:coverage`.
77
+ * `skipWhenUnchanged` decides *whether* to capture: no changed file under
78
+ * `crap.targetDirs` versus `baseRef` means no capture at all. It is on by
79
+ * default because it is gate-semantics-neutral the gates score exactly what
80
+ * they scored before, since nothing they score moved.
81
+ *
82
+ * `baselineJoin` lets the CRAP join resolve a method in an untouched file
83
+ * from its committed baseline row instead of requiring fresh coverage for it.
84
+ * That *loosens* the gate, so it stays off by default. Bundling the two under
85
+ * one `enabled` switch is precisely what forced the earlier default flip to
86
+ * be reverted (Story #5173).
87
+ *
88
+ * Neither switch shortens the capture run — a capture that does happen is the
89
+ * ordinary full `npm run test:coverage` (Story #5065). `baseRef: null` means
90
+ * "use the caller's own ref resolution" (the gate's `--ref` flag / `main`)
91
+ * rather than a second, possibly-conflicting default.
88
92
  */
89
93
  const DEFAULT_INCREMENTAL_COVERAGE = Object.freeze({
90
- enabled: false,
94
+ skipWhenUnchanged: true,
95
+ baselineJoin: false,
91
96
  baseRef: null,
92
97
  });
93
98
 
@@ -263,19 +268,29 @@ function resolveResolutionRate(value, fallback) {
263
268
  }
264
269
 
265
270
  /**
266
- * Resolve `gates.crap.incrementalCoverage` (Story #4981). A malformed or
267
- * absent user block resolves to the framework default (disabled), so a
268
- * consumer that never sets the key gets the exact pre-#4981 shape back.
271
+ * Resolve `gates.crap.incrementalCoverage` (Story #4981, split by #5173).
272
+ *
273
+ * Precedence, lowest to highest: the framework defaults
274
+ * (`skipWhenUnchanged: true`, `baselineJoin: false`), then the deprecated
275
+ * `enabled` alias which sets **both** switches to its value, then either
276
+ * explicit switch. A malformed or absent user block resolves to the defaults,
277
+ * so a consumer that never sets the key inherits the saving without the
278
+ * loosening.
269
279
  *
270
- * @param {{ enabled?: boolean, baseRef?: string } | undefined} user
271
- * @param {{ enabled: boolean, baseRef: string | null }} defaults
272
- * @returns {{ enabled: boolean, baseRef: string | null }}
280
+ * @param {{ skipWhenUnchanged?: boolean, baselineJoin?: boolean, enabled?: boolean, baseRef?: string } | undefined} user
281
+ * @param {{ skipWhenUnchanged: boolean, baselineJoin: boolean, baseRef: string | null }} defaults
282
+ * @returns {{ skipWhenUnchanged: boolean, baselineJoin: boolean, baseRef: string | null }}
273
283
  */
274
284
  function resolveIncrementalCoverage(user, defaults) {
275
285
  if (user == null || typeof user !== 'object') return { ...defaults };
286
+ const alias = typeof user.enabled === 'boolean' ? user.enabled : null;
287
+ const pick = (explicit, fallback) => {
288
+ if (typeof explicit === 'boolean') return explicit;
289
+ return alias === null ? fallback : alias;
290
+ };
276
291
  return {
277
- enabled:
278
- typeof user.enabled === 'boolean' ? user.enabled : defaults.enabled,
292
+ skipWhenUnchanged: pick(user.skipWhenUnchanged, defaults.skipWhenUnchanged),
293
+ baselineJoin: pick(user.baselineJoin, defaults.baselineJoin),
279
294
  baseRef:
280
295
  typeof user.baseRef === 'string' && user.baseRef.length > 0
281
296
  ? user.baseRef
@@ -39,6 +39,12 @@ const EXECUTION_SCHEMA = {
39
39
  'Per-command timeout (ms) for the long-running spawns delivery drives — the close-validation chain and the gate CLIs.',
40
40
  default: LIMITS_DEFAULTS.executionTimeoutMs,
41
41
  },
42
+ fullSuiteLock: {
43
+ type: 'boolean',
44
+ description:
45
+ 'Serialize full-suite spawns (`npm test` / `npm run test:coverage`) behind a host-level advisory lock, so two concurrent deliveries on one checkout do not run two suites against the same cores. Best-effort: a wait that expires spawns anyway, so the lock can never fail a delivery. Set false — or export `MANDREL_FULL_SUITE_LOCK=0` for one invocation — to disable.',
46
+ default: true,
47
+ },
42
48
  },
43
49
  additionalProperties: false,
44
50
  };
@@ -456,7 +456,7 @@ const GITHUB_SCHEMA = {
456
456
  const PLANNING_SCHEMA = {
457
457
  type: 'object',
458
458
  description:
459
- 'Inputs to `/mandrel-plan`: risk escalation heuristics, ceremony-lite routing, and the cross-Story conflict-finding severity gates.',
459
+ 'Inputs to `/mandrel-plan`: risk escalation heuristics, ceremony-lite routing, the memory-hygiene advisory thresholds, and the cross-Story conflict-finding severity gates.',
460
460
  properties: {
461
461
  riskHeuristics: {
462
462
  ...LIST_OR_EXTENDER_OF_STRINGS,
@@ -498,6 +498,34 @@ const PLANNING_SCHEMA = {
498
498
  },
499
499
  additionalProperties: false,
500
500
  },
501
+ // Story #5182 — the `/mandrel-plan` Phase 0 memory-hygiene advisory's two
502
+ // arms. The count arm this replaced was an absolute ceiling, which no
503
+ // consolidation pass could ever bring a pool back under; `growthDelta`
504
+ // measures entries written since the last pass instead, which a pass
505
+ // does reset. Both are advisory thresholds — nothing here gates a plan.
506
+ memoryPool: {
507
+ type: 'object',
508
+ description:
509
+ 'Thresholds for the memory-hygiene advisory `/mandrel-plan` surfaces at Gate #1. Advisory only: it recommends `/memory-consolidate` and never gates, reroutes, or mutates the memory pool.',
510
+ properties: {
511
+ staleAfterDays: {
512
+ type: 'integer',
513
+ minimum: 1,
514
+ description:
515
+ "Recommend a consolidation pass once the pool's stamp is older than this many days. Default 30.",
516
+ default: 30,
517
+ },
518
+ growthDelta: {
519
+ type: 'integer',
520
+ minimum: 1,
521
+ description:
522
+ 'Recommend a consolidation pass once this many entries have been written since the last one. Measured against the entry count the last pass stamped, so a stamp predating that field leaves growth unmeasured and only the age threshold applies. Default 25.',
523
+ default: 25,
524
+ },
525
+ },
526
+ additionalProperties: false,
527
+ },
528
+
501
529
  // Cross-Story conflict-finding severity gates. Off by default so
502
530
  // existing repos keep advisory-only behaviour; flipping either to
503
531
  // `true` upgrades the matching finding class to `'hard'`, which routes
@@ -11,16 +11,22 @@
11
11
  import path from 'node:path';
12
12
 
13
13
  /**
14
- * Run the incremental capture path when
15
- * `delivery.quality.gates.crap.incrementalCoverage.enabled` is true.
14
+ * Run the skip-aware capture path when
15
+ * `delivery.quality.gates.crap.incrementalCoverage.skipWhenUnchanged` is true
16
+ * (the default since Story #5173).
16
17
  *
17
18
  * **This does not shorten the capture run.** The changed-file set decides
18
19
  * *whether* to capture, never *what* the capture executes: when nothing under
19
20
  * `crap.targetDirs` changed there is no capture at all, and otherwise the
20
21
  * ordinary full `npm run test:coverage` runs. The saving that makes the mode
21
- * worth having is the skip; the other half is the CRAP join, which resolves
22
- * methods in untouched files from the committed baseline row
23
- * (`crap-baseline-join.js`) instead of demanding fresh coverage for them.
22
+ * worth having is the skip.
23
+ *
24
+ * Gated by `skipWhenUnchanged` alone (Story #5173). It MUST NOT consult
25
+ * `baselineJoin`: that switch governs the CRAP join
26
+ * (`crap-baseline-join.js`), which resolves methods in untouched files from
27
+ * the committed baseline row instead of demanding fresh coverage for them —
28
+ * a gate loosening, where the skip is a pure saving. The two are defaulted
29
+ * differently for exactly that reason, so neither may read the other.
24
30
  *
25
31
  * Returns the process exit code when incremental mode handled the run
26
32
  * (skip, capture, or a capture failure), or `null` when the caller should
@@ -54,7 +60,7 @@ export function tryIncrementalCapture({
54
60
  writeCaptureStampImpl,
55
61
  logger,
56
62
  }) {
57
- if (crap.incrementalCoverage?.enabled !== true) return null;
63
+ if (crap.incrementalCoverage?.skipWhenUnchanged !== true) return null;
58
64
 
59
65
  const ref = crap.incrementalCoverage.baseRef || args.ref;
60
66
  let changed = null;
@@ -176,14 +176,18 @@ export function resolveRawRow(mr, { requireCoverage, coverageAvailable }) {
176
176
  }
177
177
 
178
178
  /**
179
- * Incremental-mode join (Story #4981): resolve a file's raw method rows
180
- * against its committed CRAP-baseline rows instead of requiring fresh
181
- * coverage, for a file the diff did NOT touch.
179
+ * Baseline-join mode (Story #4981; gated by
180
+ * `incrementalCoverage.baselineJoin` since Story #5173): resolve a file's raw
181
+ * method rows against its committed CRAP-baseline rows instead of requiring
182
+ * fresh coverage, for a file the diff did NOT touch.
182
183
  *
183
- * Rationale: `coverage-capture`'s incremental mode only runs the consumer's
184
- * test suite scoped to the diff, so an untouched file's coverage entry may
185
- * legitimately be absent even though nothing about that file's methods
186
- * changed. Requiring a fresh join for it would either (a) skip-and-count
184
+ * Rationale: when the capture was skipped because nothing under
185
+ * `crap.targetDirs` changed, the coverage artifact on disk is whatever the
186
+ * last run left so an untouched file's coverage entry may legitimately be
187
+ * absent even though nothing about that file's methods changed. (The capture
188
+ * run itself is never narrowed: a capture that *does* happen is the ordinary
189
+ * full suite — Story #5065.) Requiring a fresh join for it would either
190
+ * (a) skip-and-count
187
191
  * every one of its methods under `requireCoverage: true`, weakening the
188
192
  * gate's signal for the vast majority of the tree on every run, or (b) score
189
193
  * them at an invented 0% under `requireCoverage: false`, manufacturing a