mandrel 2.41.0 → 2.42.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 (33) hide show
  1. package/.agents/agents/story-worker.md +24 -14
  2. package/.agents/docs/agentrc-reference.json +7 -2
  3. package/.agents/docs/configuration.md +5 -2
  4. package/.agents/schemas/agentrc.schema.json +17 -2
  5. package/.agents/schemas/validation-evidence.schema.json +3 -1
  6. package/.agents/scripts/acceptance-eval.js +68 -3
  7. package/.agents/scripts/coverage-capture.js +25 -8
  8. package/.agents/scripts/lib/baselines/crap-preview-incremental.js +7 -2
  9. package/.agents/scripts/lib/baselines/git-base.js +74 -38
  10. package/.agents/scripts/lib/close-validation/gates.js +153 -25
  11. package/.agents/scripts/lib/close-validation/process.js +30 -1
  12. package/.agents/scripts/lib/close-validation/runner.js +5 -0
  13. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +33 -12
  14. package/.agents/scripts/lib/config/quality.js +36 -21
  15. package/.agents/scripts/lib/config-settings-schema-delivery.js +6 -0
  16. package/.agents/scripts/lib/coverage-capture-incremental.js +12 -6
  17. package/.agents/scripts/lib/crap-baseline-join.js +11 -7
  18. package/.agents/scripts/lib/full-suite-lock.js +311 -0
  19. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  20. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +11 -104
  21. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +320 -0
  22. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  23. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +83 -4
  24. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +39 -7
  25. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +70 -18
  26. package/.agents/scripts/lib/orchestration/verify-credit.js +207 -0
  27. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +24 -0
  28. package/.agents/workflows/helpers/acceptance-self-eval.md +12 -0
  29. package/.agents/workflows/helpers/deliver-digest.md +31 -10
  30. package/.agents/workflows/helpers/deliver-story-reference.md +50 -30
  31. package/.agents/workflows/helpers/deliver-story.md +23 -21
  32. package/docs/CHANGELOG.md +18 -0
  33. package/package.json +1 -1
@@ -7,22 +7,16 @@
7
7
  * @module lib/orchestration/check-baselines/phases/evaluate
8
8
  */
9
9
 
10
- import { resolveKindRefreshOverrides } from '../../../baselines/env-overrides.js';
11
- import { readRangeSubjectsTouchingFile } from '../../../baselines/git-base.js';
12
10
  import {
13
11
  checkBaselineSemantics,
14
12
  checkKernelVersion,
15
13
  getKindModule,
16
14
  } from '../../../baselines/kernel.js';
17
15
  import * as reader from '../../../baselines/reader.js';
18
- import { Logger } from '../../../Logger.js';
19
16
  import { isIgnoredByGlobs } from '../../../maintainability-utils.js';
20
17
  import { applyTolerance, evaluateCompare, runCompareStage } from './compare.js';
21
18
  import { applyFloors, flattenBreaches } from './floors.js';
22
- import { DEFAULT_BASELINE_PATHS } from './parse-args.js';
23
-
24
- /** Default refresh-tag substring when the gate omits `refreshTag`. */
25
- const DEFAULT_REFRESH_TAG = 'baseline-refresh:';
19
+ import { applyRefreshAcknowledgment } from './refresh-ack.js';
26
20
 
27
21
  /**
28
22
  * Defense-in-depth against an `ignoreGlobs`-poisoned baseline (Epic #4326
@@ -87,97 +81,6 @@ function loadHeadBaseline(kind, cwd, configPath) {
87
81
  }
88
82
  }
89
83
 
90
- /**
91
- * Resolve the one-shot refresh trigger for any ratcheted kind (Story #4802,
92
- * generalizing Story #151's bundle-size env flag and Story #4731's
93
- * maintainability env-or-commit-tag pair). Two paths, either of which
94
- * acknowledges:
95
- *
96
- * 1. Env parity: `<KIND>_REFRESH=1` (the manual override) — upper-snaked,
97
- * so the two pre-existing names (`BUNDLE_SIZE_REFRESH`,
98
- * `MAINTAINABILITY_REFRESH`) keep working unchanged.
99
- * 2. Commit tag: a commit in the compared range `<baseRef>..HEAD` whose
100
- * subject contains the configured `refreshTag` AND whose diff touches
101
- * that kind's baseline file. One-shot by construction — once merged, the
102
- * refreshed baseline becomes the base and the tag leaves the range.
103
- *
104
- * The tag is matched as a plain substring of a conventional commit subject, so
105
- * commitlint stays satisfied (e.g. `chore(baselines): baseline-refresh: …`).
106
- *
107
- * Fails closed: a kind whose baseline path is neither configured nor present
108
- * in `DEFAULT_BASELINE_PATHS` simply skips the commit-tag path rather than
109
- * throwing, leaving the run un-acknowledged.
110
- *
111
- * @returns {{ triggered: boolean, reasons: string[] }}
112
- */
113
- function resolveRefreshTrigger({ kind, gateBlock, cmp, cwd, env }) {
114
- const reasons = [];
115
- const { acknowledged: envAck, overrides } = resolveKindRefreshOverrides(
116
- kind,
117
- env,
118
- );
119
- if (envAck) reasons.push(...overrides);
120
-
121
- const baseRef = cmp?.baseRef ?? null;
122
- if (baseRef) {
123
- const refreshTag =
124
- typeof gateBlock?.refreshTag === 'string' && gateBlock.refreshTag.length
125
- ? gateBlock.refreshTag
126
- : DEFAULT_REFRESH_TAG;
127
- const baselinePath =
128
- typeof gateBlock?.baselinePath === 'string' &&
129
- gateBlock.baselinePath.length
130
- ? gateBlock.baselinePath
131
- : DEFAULT_BASELINE_PATHS[kind];
132
- if (typeof baselinePath === 'string' && baselinePath.length) {
133
- const subjects = readRangeSubjectsTouchingFile(baseRef, baselinePath, {
134
- cwd,
135
- });
136
- const match = subjects.find((s) => s.includes(refreshTag));
137
- if (match) {
138
- reasons.push(
139
- `refresh commit "${match}" (subject contains ${JSON.stringify(refreshTag)}, touches ${baselinePath})`,
140
- );
141
- }
142
- }
143
- }
144
-
145
- return { triggered: reasons.length > 0, reasons };
146
- }
147
-
148
- /**
149
- * One-shot baseline refresh/acknowledge for any ratcheted kind (Story #4802).
150
- * When triggered (env flag OR a `baseline-refresh:`-tagged range commit
151
- * touching that kind's baseline), demote every head-vs-base regression to
152
- * `unchanged` for this run only — floors still apply, so a row below its floor
153
- * still breaches. The trigger is read fresh every run and never persisted:
154
- * post-merge the refreshed baseline is the new base and the tag leaves the
155
- * range, so the ratchet returns to full strength automatically.
156
- *
157
- * A no-op absent a trigger, so an unacknowledged run of any kind reports its
158
- * regressions exactly as before.
159
- */
160
- function applyRefreshAcknowledgment(kind, compareOutput, ctx) {
161
- const { triggered, reasons } = resolveRefreshTrigger({ ...ctx, kind });
162
- if (!triggered || compareOutput.regressions.length === 0) {
163
- return { compareOutput, acknowledged: false };
164
- }
165
- Logger.warn(
166
- `[${kind}] ⚠ ${reasons.join('; ')} — ` +
167
- `${compareOutput.regressions.length} regression(s) acknowledged for this run only; ` +
168
- 'floors still enforced. This does not persist: once the refresh is the ' +
169
- 'new base the ratchet re-enforces at full strength.',
170
- );
171
- return {
172
- acknowledged: true,
173
- compareOutput: {
174
- ...compareOutput,
175
- regressions: [],
176
- unchanged: [...compareOutput.unchanged, ...compareOutput.regressions],
177
- },
178
- };
179
- }
180
-
181
84
  function buildGateReport({
182
85
  kind,
183
86
  gateBlock,
@@ -186,7 +89,7 @@ function buildGateReport({
186
89
  breaches,
187
90
  compareOutput,
188
91
  cmp,
189
- acknowledged,
92
+ ack,
190
93
  }) {
191
94
  const kernel = checkKernelVersion(kind, baseline.kernelVersion);
192
95
  return {
@@ -212,7 +115,12 @@ function buildGateReport({
212
115
  // the JSON report alone.
213
116
  baseRead: cmp.baseRead === true,
214
117
  generatedAt: baseline.generatedAt,
215
- acknowledged,
118
+ acknowledged: ack.acknowledged,
119
+ // Story #5179 — `acknowledged` is now a PARTIAL statement: a refresh commit
120
+ // clears only the rows it actually refreshed, so a run can acknowledge some
121
+ // regressions and still fail on others. Naming the acknowledged keys makes
122
+ // which-half-was-which readable from the JSON report without re-walking git.
123
+ acknowledgedKeys: ack.acknowledgedKeys,
216
124
  };
217
125
  }
218
126
 
@@ -258,17 +166,16 @@ export async function evaluateKind({
258
166
  cmp,
259
167
  cwd,
260
168
  env,
169
+ headBaseline: baseline,
261
170
  });
262
- const compareOutput = ack.compareOutput;
263
- const acknowledged = ack.acknowledged;
264
171
  return buildGateReport({
265
172
  kind,
266
173
  gateBlock,
267
174
  baseline,
268
175
  findings,
269
176
  breaches,
270
- compareOutput,
177
+ compareOutput: ack.compareOutput,
271
178
  cmp,
272
- acknowledged,
179
+ ack,
273
180
  });
274
181
  }
@@ -0,0 +1,320 @@
1
+ /**
2
+ * refresh-ack.js — the one-shot baseline refresh acknowledgment (Story #5179).
3
+ *
4
+ * Extracted from `evaluate.js`, where it grew from a bundle-size env flag
5
+ * (Story #151) through a maintainability env-or-commit-tag pair (Story #4731)
6
+ * into the kind-generic trigger (Story #4802). Story #5179 narrowed the
7
+ * commit-tag arm from a whole-run blanket to the rows the tagged commit
8
+ * actually refreshed, which is enough logic to own its own module.
9
+ *
10
+ * @module lib/orchestration/check-baselines/phases/refresh-ack
11
+ */
12
+
13
+ import { resolveKindRefreshOverrides } from '../../../baselines/env-overrides.js';
14
+ import {
15
+ readBaseFromGit,
16
+ readRangeCommitsTouchingFile,
17
+ } from '../../../baselines/git-base.js';
18
+ import { getKindModule } from '../../../baselines/kernel.js';
19
+ import { Logger } from '../../../Logger.js';
20
+ import { applyTolerance } from './compare.js';
21
+ import { DEFAULT_BASELINE_PATHS } from './parse-args.js';
22
+
23
+ /** Default refresh-tag substring when the gate omits `refreshTag`. */
24
+ const DEFAULT_REFRESH_TAG = 'baseline-refresh:';
25
+
26
+ function resolveBaselinePath(kind, gateBlock) {
27
+ const configured =
28
+ typeof gateBlock?.baselinePath === 'string' && gateBlock.baselinePath.length
29
+ ? gateBlock.baselinePath
30
+ : null;
31
+ return configured ?? DEFAULT_BASELINE_PATHS[kind] ?? null;
32
+ }
33
+
34
+ function resolveRefreshTag(gateBlock) {
35
+ return typeof gateBlock?.refreshTag === 'string' &&
36
+ gateBlock.refreshTag.length
37
+ ? gateBlock.refreshTag
38
+ : DEFAULT_REFRESH_TAG;
39
+ }
40
+
41
+ /**
42
+ * Resolve the one-shot refresh trigger for any ratcheted kind. Two paths, either
43
+ * of which acknowledges:
44
+ *
45
+ * 1. Env parity: `<KIND>_REFRESH=1` (the manual override) — upper-snaked, so
46
+ * the two pre-existing names (`BUNDLE_SIZE_REFRESH`,
47
+ * `MAINTAINABILITY_REFRESH`) keep working unchanged.
48
+ * 2. Commit tag: a commit in the compared range `<baseRef>..HEAD` whose
49
+ * subject contains the configured `refreshTag` AND whose diff touches that
50
+ * kind's baseline file. One-shot by construction — once merged, the
51
+ * refreshed baseline becomes the base and the tag leaves the range.
52
+ *
53
+ * The tag is matched as a plain substring of a conventional commit subject, so
54
+ * commitlint stays satisfied (e.g. `chore(baselines): baseline-refresh: …`).
55
+ *
56
+ * The two arms are reported separately because Story #5179 scopes them
57
+ * differently: see `applyRefreshAcknowledgment`.
58
+ *
59
+ * Fails closed: a kind whose baseline path is neither configured nor present in
60
+ * `DEFAULT_BASELINE_PATHS` simply skips the commit-tag path rather than
61
+ * throwing, leaving that arm un-acknowledged.
62
+ *
63
+ * @returns {{ triggered: boolean, reasons: string[], envAcknowledged: boolean,
64
+ * refreshCommits: { sha: string, subject: string }[], baselinePath: string | null }}
65
+ */
66
+ function resolveRefreshTrigger({ kind, gateBlock, cmp, cwd, env }) {
67
+ const reasons = [];
68
+ const { acknowledged: envAcknowledged, overrides } =
69
+ resolveKindRefreshOverrides(kind, env);
70
+ if (envAcknowledged) reasons.push(...overrides);
71
+
72
+ const baselinePath = resolveBaselinePath(kind, gateBlock);
73
+ const refreshCommits = [];
74
+ const baseRef = cmp?.baseRef ?? null;
75
+ if (baseRef && typeof baselinePath === 'string' && baselinePath.length) {
76
+ const refreshTag = resolveRefreshTag(gateBlock);
77
+ const commits = readRangeCommitsTouchingFile(baseRef, baselinePath, {
78
+ cwd,
79
+ });
80
+ for (const commit of commits) {
81
+ if (!commit.subject.includes(refreshTag)) continue;
82
+ refreshCommits.push(commit);
83
+ reasons.push(
84
+ `refresh commit "${commit.subject}" (subject contains ${JSON.stringify(refreshTag)}, touches ${baselinePath})`,
85
+ );
86
+ }
87
+ }
88
+
89
+ return {
90
+ triggered: reasons.length > 0,
91
+ reasons,
92
+ envAcknowledged,
93
+ refreshCommits,
94
+ baselinePath,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Read the baseline rows as of one refresh commit. Returns `null` — never
100
+ * throws and never a partial row set — when the blob is absent, unreadable or
101
+ * unparseable at that SHA. The caller treats `null` as "this commit
102
+ * acknowledges nothing", which keeps the ratchet at full strength rather than
103
+ * acknowledging on a guess.
104
+ */
105
+ function readRowsAtCommit(sha, baselinePath, cwd) {
106
+ let raw;
107
+ try {
108
+ raw = readBaseFromGit(sha, baselinePath, { cwd });
109
+ } catch {
110
+ return null;
111
+ }
112
+ if (raw === null) return null;
113
+ try {
114
+ const payload = JSON.parse(raw);
115
+ return Array.isArray(payload?.rows) ? payload.rows : null;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Classify the head rows against one refresh commit's rows, using the kind's
123
+ * own classifier and the gate's own tolerance.
124
+ *
125
+ * Row identity is deliberately never derived here. A kind's `keyField` names
126
+ * the row property the kind is *about*, which is not always its compare key:
127
+ * CRAP declares `keyField: 'path'` but keys rows as `path::method@startLine`,
128
+ * because one file holds many methods. Reading `row[keyField]` would produce a
129
+ * key matching no regression, silently acknowledging nothing for that kind.
130
+ * `compare()` is the one thing that knows a kind's key, so every key here comes
131
+ * back out of it — the same reason direction and tolerance are delegated rather
132
+ * than reimplemented.
133
+ *
134
+ * That also collapses both tests into one classification:
135
+ *
136
+ * - `regressions` — present in both, worse at head: post-refresh drift.
137
+ * - `improvements` / `unchanged` — present in both, no worse: the rows this
138
+ * commit vouches for.
139
+ * - `additions` — present at head but absent from the refresh blob, i.e.
140
+ * never refreshed by this commit, so deliberately in neither set.
141
+ *
142
+ * @returns {{ ok: string[], drifted: string[] } | null} null when the
143
+ * classifier is unusable, which acknowledges nothing.
144
+ */
145
+ function classifyAgainstRefresh({ mod, headRows, refreshRows, tolerance }) {
146
+ try {
147
+ const result = mod.compare({ rows: headRows }, { rows: refreshRows });
148
+ const tolerated = applyTolerance(
149
+ {
150
+ regressions: result?.regressions ?? [],
151
+ improvements: result?.improvements ?? [],
152
+ unchanged: result?.unchanged ?? [],
153
+ additions: result?.additions ?? [],
154
+ },
155
+ tolerance ?? null,
156
+ );
157
+ return {
158
+ ok: [...tolerated.improvements, ...tolerated.unchanged].map((r) => r.key),
159
+ drifted: tolerated.regressions.map((r) => r.key),
160
+ };
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Which regression keys are acknowledgeable by the tagged commits (Story #5179)?
168
+ *
169
+ * The acknowledgment is a statement about what a refresh commit re-scored, so a
170
+ * key is acknowledgeable only when the commit both covered it and recorded a
171
+ * value the head has not since fallen below. Both tests come out of
172
+ * `classifyAgainstRefresh` above:
173
+ *
174
+ * 1. **Row membership** — a key absent from the refresh blob lands in
175
+ * `additions`, never in `ok`. This was the larger half of the leak: a
176
+ * single tagged commit cleared regressions on rows in directories it never
177
+ * touched.
178
+ * 2. **No post-refresh drift** — a key worse at head than the commit recorded
179
+ * lands in `drifted`. Drift from commits landing AFTER the refresh is what
180
+ * "the baseline commit must be the branch's last score-moving commit" asks
181
+ * for by convention and nothing enforced.
182
+ *
183
+ * Fails closed at every step: an unreadable blob, a missing `compare`, or a
184
+ * classifier that throws contributes nothing, so those regressions stand.
185
+ *
186
+ * @returns {Set<string>}
187
+ */
188
+ function acknowledgeableKeys({
189
+ kind,
190
+ headBaseline,
191
+ refreshCommits,
192
+ baselinePath,
193
+ cwd,
194
+ tolerance,
195
+ }) {
196
+ const acknowledgeable = new Set();
197
+ if (refreshCommits.length === 0) return acknowledgeable;
198
+ if (typeof baselinePath !== 'string' || baselinePath.length === 0)
199
+ return acknowledgeable;
200
+
201
+ let mod;
202
+ try {
203
+ mod = getKindModule(kind);
204
+ } catch {
205
+ return acknowledgeable;
206
+ }
207
+ if (typeof mod?.compare !== 'function') return acknowledgeable;
208
+
209
+ const headRows = Array.isArray(headBaseline?.rows) ? headBaseline.rows : [];
210
+ // `readRangeCommitsTouchingFile` returns newest-first, and a key the newest
211
+ // refresh already ruled on is not reopened by an older one: the newest is the
212
+ // state the branch is asking to be held to.
213
+ const decided = new Set();
214
+ for (const { sha } of refreshCommits) {
215
+ const refreshRows = readRowsAtCommit(sha, baselinePath, cwd);
216
+ if (refreshRows === null) continue;
217
+ const verdict = classifyAgainstRefresh({
218
+ mod,
219
+ headRows,
220
+ refreshRows,
221
+ tolerance,
222
+ });
223
+ if (verdict === null) continue;
224
+ for (const key of verdict.ok) {
225
+ if (decided.has(key)) continue;
226
+ decided.add(key);
227
+ acknowledgeable.add(key);
228
+ }
229
+ for (const key of verdict.drifted) decided.add(key);
230
+ }
231
+ return acknowledgeable;
232
+ }
233
+
234
+ function partitionRegressions(regressions, isAcknowledgeable) {
235
+ const acknowledged = [];
236
+ const kept = [];
237
+ for (const reg of regressions) {
238
+ if (isAcknowledgeable(reg)) acknowledged.push(reg);
239
+ else kept.push(reg);
240
+ }
241
+ return { acknowledged, kept };
242
+ }
243
+
244
+ function logAcknowledgment({ kind, reasons, acknowledged, kept }) {
245
+ const held =
246
+ kept.length > 0
247
+ ? `${kept.length} regression(s) NOT acknowledged (outside the refreshed rows, or drifted further after the refresh) and still fail the gate; `
248
+ : '';
249
+ Logger.warn(
250
+ `[${kind}] ⚠ ${reasons.join('; ')} — ` +
251
+ `${acknowledged.length} regression(s) acknowledged for this run only; ` +
252
+ `${held}floors still enforced. This does not persist: once the refresh is ` +
253
+ 'the new base the ratchet re-enforces at full strength.',
254
+ );
255
+ }
256
+
257
+ /**
258
+ * One-shot baseline refresh/acknowledge for any ratcheted kind. When triggered,
259
+ * demote acknowledged head-vs-base regressions to `unchanged` for this run only
260
+ * — floors still apply, so a row below its floor still breaches. The trigger is
261
+ * read fresh every run and never persisted: post-merge the refreshed baseline is
262
+ * the new base and the tag leaves the range, so the ratchet returns to full
263
+ * strength automatically.
264
+ *
265
+ * The two trigger arms are scoped differently, deliberately:
266
+ *
267
+ * - **Commit tag** — scoped to the rows the tagged commits actually refreshed,
268
+ * per `acknowledgeableKeys`. Before Story #5179 this cleared every regression
269
+ * in the range, so a branch merged carrying a stale row and the ratchet ran
270
+ * loose on that file; the failure recurred six times.
271
+ * - **Env parity** (`<KIND>_REFRESH=1`) — stays whole-run. There is no commit to
272
+ * anchor row-scoping to, and setting the variable is an explicit, deliberate
273
+ * operator act rather than a signal inferred from history.
274
+ *
275
+ * A no-op absent a trigger, so an unacknowledged run of any kind reports its
276
+ * regressions exactly as before.
277
+ *
278
+ * @returns {{ compareOutput: object, acknowledged: boolean, acknowledgedKeys: string[] }}
279
+ */
280
+ export function applyRefreshAcknowledgment(kind, compareOutput, ctx) {
281
+ const trigger = resolveRefreshTrigger({ ...ctx, kind });
282
+ if (!trigger.triggered || compareOutput.regressions.length === 0) {
283
+ return { compareOutput, acknowledged: false, acknowledgedKeys: [] };
284
+ }
285
+
286
+ let isAcknowledgeable;
287
+ if (trigger.envAcknowledged) {
288
+ isAcknowledgeable = () => true;
289
+ } else {
290
+ const keys = acknowledgeableKeys({
291
+ kind,
292
+ headBaseline: ctx.headBaseline,
293
+ refreshCommits: trigger.refreshCommits,
294
+ baselinePath: trigger.baselinePath,
295
+ cwd: ctx.cwd,
296
+ tolerance: ctx.gateBlock?.tolerance ?? null,
297
+ });
298
+ isAcknowledgeable = (reg) => keys.has(reg.key);
299
+ }
300
+
301
+ const { acknowledged, kept } = partitionRegressions(
302
+ compareOutput.regressions,
303
+ isAcknowledgeable,
304
+ );
305
+ if (acknowledged.length === 0) {
306
+ return { compareOutput, acknowledged: false, acknowledgedKeys: [] };
307
+ }
308
+
309
+ logAcknowledgment({ kind, reasons: trigger.reasons, acknowledged, kept });
310
+
311
+ return {
312
+ acknowledged: true,
313
+ acknowledgedKeys: acknowledged.map((reg) => reg.key),
314
+ compareOutput: {
315
+ ...compareOutput,
316
+ regressions: kept,
317
+ unchanged: [...compareOutput.unchanged, ...acknowledged],
318
+ },
319
+ };
320
+ }
@@ -34,7 +34,14 @@ function formatGateLine(g) {
34
34
  g.baseRef && g.baseRead === false
35
35
  ? ' [baseRead=false — compare skipped]'
36
36
  : '';
37
- const ack = g.acknowledged ? ' [ACKNOWLEDGED this run only]' : '';
37
+ // Story #5179 a refresh commit acknowledges only the rows it refreshed, so
38
+ // the count says how much was cleared and the status ahead of it still shows
39
+ // FAIL when regressions outside those rows survived. `buildGateReport` always
40
+ // sets `acknowledgedKeys` alongside `acknowledged` from the same result
41
+ // object, so the array needs no guard of its own here.
42
+ const ack = g.acknowledged
43
+ ? ` [ACKNOWLEDGED ${g.acknowledgedKeys.length} row(s) — this run only]`
44
+ : '';
38
45
  return ` - ${g.kind}: ${status}${drift}${baseRef}${baseRead}${ack}`;
39
46
  }
40
47
 
@@ -27,8 +27,12 @@ import {
27
27
  const PHASE_ORDER = Object.freeze([
28
28
  'init',
29
29
  'wrong-tree-guard',
30
- 'close-validation',
30
+ // Story #5172 — base-sync now precedes close-validation, so the tree the
31
+ // gates validate is the tree the push sends. The order here is not
32
+ // decoration: it is how a failed terminal decides which gates had already
33
+ // cleared, so it MUST track `runPrePushPhases`.
31
34
  'base-sync',
35
+ 'close-validation',
32
36
  'push',
33
37
  'pull-request',
34
38
  'code-review',
@@ -45,6 +49,62 @@ const GATE_PHASES = Object.freeze([
45
49
  ['codeReview', 'code-review'],
46
50
  ]);
47
51
 
52
+ /**
53
+ * The names the split baselines gate registers under, mirrored from
54
+ * `BASELINES_GATE_NAMES` in `lib/close-validation/gates.js` (Story #5172).
55
+ *
56
+ * Deliberately a local copy rather than an import: several close suites
57
+ * replace that module wholesale via `t.mock.module`, and a named import here
58
+ * would fail to link against a mock that does not re-export the constant —
59
+ * turning an unrelated test's mock into a load error on the CLI's own entry
60
+ * path. `tests/close-validation-gates-enum.test.js` pins the two lists
61
+ * against each other so the copy cannot drift.
62
+ */
63
+ const BASELINES_ENTRY_NAMES = Object.freeze([
64
+ 'check-baselines-independent',
65
+ 'check-baselines-coverage',
66
+ ]);
67
+
68
+ /**
69
+ * Outcome for each split baselines entry on a run that died at `phase`.
70
+ *
71
+ * The two entries sit in ONE pipeline phase, so the phase walk alone cannot
72
+ * separate them — `failedGate` (tagged onto the error by the close-validation
73
+ * phase) is what names the entry that actually broke. Rules, in the module's
74
+ * house style of never claiming a pass it cannot prove:
75
+ * - validation skipped, or the run died before reaching it → both `skipped`.
76
+ * - the run cleared validation entirely → both `passed`.
77
+ * - the run died IN validation on the coverage-independent entry → that one
78
+ * `failed`, the coverage one `skipped` (it runs behind `coverage-capture`,
79
+ * which the failure pre-empted).
80
+ * - died on the coverage-consuming entry → that one `failed`, and the
81
+ * independent one `passed`: it is in the parallel partition that must go
82
+ * green before any serial gate starts.
83
+ * - died in validation on some other gate → both `skipped`; which of them
84
+ * had run is not knowable from the phase alone.
85
+ *
86
+ * @param {string} phase
87
+ * @param {{ skipValidation?: boolean, failedGate?: string|null }} args
88
+ * @returns {Record<string, 'passed'|'failed'|'skipped'>}
89
+ */
90
+ function baselinesGatesForFailedPhase(phase, { skipValidation, failedGate }) {
91
+ const [independent, coverage] = BASELINES_ENTRY_NAMES;
92
+ const both = (outcome) => ({ [independent]: outcome, [coverage]: outcome });
93
+ const failedAt = PHASE_ORDER.indexOf(phase);
94
+ const validationAt = PHASE_ORDER.indexOf('close-validation');
95
+ if (skipValidation || failedAt < 0 || failedAt < validationAt) {
96
+ return both('skipped');
97
+ }
98
+ if (failedAt > validationAt) return both('passed');
99
+ if (failedGate === independent) {
100
+ return { [independent]: 'failed', [coverage]: 'skipped' };
101
+ }
102
+ if (failedGate === coverage) {
103
+ return { [independent]: 'passed', [coverage]: 'failed' };
104
+ }
105
+ return both('skipped');
106
+ }
107
+
48
108
  /**
49
109
  * Report every gate's outcome for a run that died at `phase`.
50
110
  *
@@ -59,8 +119,14 @@ const GATE_PHASES = Object.freeze([
59
119
  * turned off via `--skip-validation` / `--skip-sync` is `skipped` too (it did
60
120
  * not pass — it never ran).
61
121
  *
122
+ * Story #5172 — the reported set also carries the two split baselines
123
+ * entries under their own names, so a failed close says WHICH half of the
124
+ * baselines gate breached instead of a single generic verdict.
125
+ *
62
126
  * @param {string} phase The phase the run died in.
63
- * @param {{ skipValidation?: boolean, skipSync?: boolean }} args Parsed CLI args.
127
+ * @param {{ skipValidation?: boolean, skipSync?: boolean, failedGate?: string|null }} args
128
+ * Parsed CLI args, plus the gate name tagged onto the error by the
129
+ * close-validation phase.
64
130
  * @returns {Record<string, 'passed'|'failed'|'skipped'>}
65
131
  */
66
132
  export function gatesForFailedPhase(phase, args = {}) {
@@ -73,7 +139,13 @@ export function gatesForFailedPhase(phase, args = {}) {
73
139
  else if (failedAt < 0 || at > failedAt) gates[gate] = 'skipped';
74
140
  else gates[gate] = skipped[gate] ? 'skipped' : 'passed';
75
141
  }
76
- return gates;
142
+ return {
143
+ ...gates,
144
+ ...baselinesGatesForFailedPhase(phase, {
145
+ skipValidation: args.skipValidation,
146
+ failedGate: args.failedGate ?? null,
147
+ }),
148
+ };
77
149
  }
78
150
 
79
151
  /**
@@ -91,6 +163,10 @@ export function gatesForFailedPhase(phase, args = {}) {
91
163
  * holding the script had been reaped mid-run. On failure this returns null
92
164
  * and the caller rethrows the original.
93
165
  *
166
+ * `err.closeGate` — tagged by the close-validation phase — names the gate that
167
+ * died inside that phase, which is what lets the reported gates separate the
168
+ * two split baselines entries (Story #5172).
169
+ *
94
170
  * @param {unknown} err
95
171
  * @param {{ storyId?: string|number, skipValidation?: boolean, skipSync?: boolean }} args
96
172
  * Parsed CLI args — the story id the envelope reports on, plus the skip
@@ -108,7 +184,10 @@ export function failedTerminalFor(err, args = {}) {
108
184
  storyId,
109
185
  status: 'failed',
110
186
  phase,
111
- gates: gatesForFailedPhase(phase, args),
187
+ gates: gatesForFailedPhase(phase, {
188
+ ...args,
189
+ failedGate: err?.closeGate ?? null,
190
+ }),
112
191
  failure: { reason: String(err?.message ?? err) },
113
192
  nextCommand: NEXT_COMMANDS.recover(storyId),
114
193
  elapsedSeconds: 0,