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
@@ -19,12 +19,28 @@
19
19
  * only the attended `/memory-consolidate` pass, reading content, can tell the
20
20
  * difference. This module counts and stats; it never judges an entry.
21
21
  *
22
+ * **Growth, never size (Story #5182).** The second arm used to be an absolute
23
+ * ceiling of a hundred entries. A consolidation pass prefers `correct` over
24
+ * `dead` by design, so a pool that crosses a fixed ceiling stays over it
25
+ * forever: the nudge then fired on every plan however fresh the stamp, and a
26
+ * permanent recommendation is one the operator learns to ignore. The arm now
27
+ * measures **entries written since the last pass** — the one quantity a pass
28
+ * actually resets, because Step 6 records the post-rewrite entry count in the
29
+ * stamp as the next run's growth baseline.
30
+ *
31
+ * A stamp carrying a date but no usable `entryCount` (every stamp written
32
+ * before that Story) leaves growth **unmeasured**. That is not
33
+ * "never consolidated" — an operator did review the pool — so the growth arm
34
+ * simply stays silent and only the age arm can speak, until the next pass
35
+ * writes a baseline.
36
+ *
22
37
  * Detection is filesystem-only — no child processes, no `gh` probes, no
23
38
  * network. Every failure path fails soft to "no pool, no recommendation": the
24
39
  * advisory can degrade the nudge, never a plan.
25
40
  *
26
41
  * Test seams: `cwd`, `env`, `fsImpl` (node:fs-compatible `statSync` /
27
- * `readdirSync` / `readFileSync`), `now`, and the two thresholds.
42
+ * `readdirSync` / `readFileSync`), `now`, and the two thresholds
43
+ * (`staleAfterDays`, `growthDelta`).
28
44
  *
29
45
  * `buildMemoryPoolAdvisory` is the **only** export: the helpers below have no
30
46
  * caller outside this module, and exporting one solely for a test would add a
@@ -40,8 +56,8 @@ import * as path from 'node:path';
40
56
  /** Recommend a consolidation pass once the stamp is this old. */
41
57
  const STALE_AFTER_DAYS = 30;
42
58
 
43
- /** Recommend a consolidation pass once the pool holds more entries than this. */
44
- const ENTRY_COUNT_CEILING = 100;
59
+ /** Recommend a pass once this many entries were written since the last one. */
60
+ const GROWTH_DELTA = 25;
45
61
 
46
62
  /** Stamp file written by `/memory-consolidate` after its operator gate. */
47
63
  const STAMP_FILENAME = '.consolidation-stamp.json';
@@ -94,21 +110,47 @@ function resolveMemoryPoolDir({ cwd, env = process.env, homedir } = {}) {
94
110
  }
95
111
 
96
112
  /**
97
- * Read the consolidation stamp, returning its ISO timestamp or `null`.
98
- * A missing, unreadable, unparseable, or malformed stamp is indistinguishable
113
+ * The growth baseline a stamp records: its entry count, or `null` when it
114
+ * records none. `null` is *unmeasured*, never zero a zero baseline would
115
+ * score every entry in the pool as newly written.
116
+ *
117
+ * @param {unknown} count
118
+ * @returns {number|null}
119
+ */
120
+ function readBaseline(count) {
121
+ return Number.isInteger(count) && count >= 0 ? count : null;
122
+ }
123
+
124
+ /**
125
+ * Read the consolidation stamp.
126
+ *
127
+ * `at` is the ISO timestamp of the last pass, or `null` when there was none:
128
+ * a missing, unreadable, unparseable or date-less stamp is indistinguishable
99
129
  * from "never consolidated" — all four mean the same thing to the advisory.
130
+ * A stamp whose date is unusable carries no baseline either, so `baseline`
131
+ * follows it to `null` rather than describing a pass that cannot be dated.
132
+ *
133
+ * `baseline` is the entry count that pass left behind — the growth arm's
134
+ * reference point. It is `null` on a stamp that predates Story #5182 (date
135
+ * only) and on a malformed count, which reads as *unmeasured growth*, never
136
+ * as zero growth: a `0` baseline would score the whole pool as new.
100
137
  *
101
- * @returns {string|null}
138
+ * @returns {{ at: string|null, baseline: number|null }}
102
139
  */
103
140
  function readStamp({ poolDir, fsImpl }) {
141
+ const unstamped = { at: null, baseline: null };
104
142
  try {
105
143
  const raw = fsImpl.readFileSync(path.join(poolDir, STAMP_FILENAME), 'utf8');
106
144
  const parsed = JSON.parse(raw);
107
- const value = parsed?.lastConsolidatedAt;
108
- if (typeof value !== 'string' || value.length === 0) return null;
109
- return Number.isNaN(Date.parse(value)) ? null : value;
145
+ const at = parsed.lastConsolidatedAt;
146
+ // `Date.parse` rejects the empty string as NaN, so this one test covers
147
+ // both an absent date and an unusable one.
148
+ if (typeof at !== 'string' || Number.isNaN(Date.parse(at))) {
149
+ return unstamped;
150
+ }
151
+ return { at, baseline: readBaseline(parsed.entryCount) };
110
152
  } catch {
111
- return null;
153
+ return unstamped;
112
154
  }
113
155
  }
114
156
 
@@ -127,6 +169,83 @@ function countEntries({ poolDir, fsImpl }) {
127
169
  }
128
170
  }
129
171
 
172
+ /**
173
+ * The advisory's field set, defaulted to the fail-soft "no usable pool"
174
+ * reading. Every return path spreads its own findings over this, so the
175
+ * envelope's shape is declared once — a new field cannot reach some callers
176
+ * and not others, which is the failure mode a per-branch object literal has.
177
+ *
178
+ * @param {object} fields
179
+ * @returns {{ present: boolean, entryCount: number, lastConsolidatedAt: string|null,
180
+ * entriesSinceConsolidation: number|null, recommend: boolean,
181
+ * reasons: string[] }}
182
+ */
183
+ function envelope(fields) {
184
+ return {
185
+ present: false,
186
+ entryCount: 0,
187
+ lastConsolidatedAt: null,
188
+ entriesSinceConsolidation: null,
189
+ recommend: false,
190
+ reasons: [],
191
+ ...fields,
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Collect the reasons a pool wants a consolidation pass. An empty array is
197
+ * the quiet verdict; the caller turns it into `recommend` and supplies the
198
+ * standing-down sentence, so every arm lives in one place.
199
+ *
200
+ * The two arms are independent and both are reported when both fire.
201
+ *
202
+ * @param {{ stamp: { at: string|null, baseline: number|null },
203
+ * growth: number|null, now: Date|string|number,
204
+ * staleAfterDays: number, growthDelta: number }} args
205
+ * @returns {string[]}
206
+ */
207
+ function collectReasons({ stamp, growth, now, staleAfterDays, growthDelta }) {
208
+ const reasons = [];
209
+
210
+ if (stamp.at === null) {
211
+ reasons.push(
212
+ 'no consolidation stamp — this pool has never been consolidated',
213
+ );
214
+ } else {
215
+ const ageDays =
216
+ (new Date(now).getTime() - Date.parse(stamp.at)) / MS_PER_DAY;
217
+ if (ageDays > staleAfterDays) {
218
+ reasons.push(
219
+ `last consolidated ${Math.floor(ageDays)} days ago (over the ${staleAfterDays}-day threshold)`,
220
+ );
221
+ }
222
+ }
223
+
224
+ // `growth === null` is unmeasured, not zero — a pre-#5182 stamp carries no
225
+ // baseline, and guessing one would re-invent the ceiling this arm replaced.
226
+ if (growth !== null && growth >= growthDelta) {
227
+ reasons.push(
228
+ `${growth} entries written since the last consolidation (at or over the ${growthDelta}-entry growth delta)`,
229
+ );
230
+ }
231
+
232
+ return reasons;
233
+ }
234
+
235
+ /**
236
+ * The sentence a quiet pool explains itself with — one per reason it is quiet,
237
+ * so "nothing to do" never reads the same as "nothing measurable".
238
+ *
239
+ * @param {{ growth: number|null, growthDelta: number }} args
240
+ * @returns {string}
241
+ */
242
+ function quietReason({ growth, growthDelta }) {
243
+ if (growth === null) {
244
+ return 'memory pool is within the freshness threshold; growth is unmeasured until the next /memory-consolidate stamps an entry count';
245
+ }
246
+ return `memory pool is within both thresholds — ${growth} entries written since the last consolidation (under the ${growthDelta}-entry growth delta)`;
247
+ }
248
+
130
249
  /**
131
250
  * Build the `memoryPoolAdvisory` envelope field.
132
251
  *
@@ -141,9 +260,10 @@ function countEntries({ poolDir, fsImpl }) {
141
260
  * @param {string} [opts.homedir]
142
261
  * @param {Date|string|number} [opts.now]
143
262
  * @param {number} [opts.staleAfterDays]
144
- * @param {number} [opts.entryCountCeiling]
263
+ * @param {number} [opts.growthDelta]
145
264
  * @returns {{ present: boolean, entryCount: number, lastConsolidatedAt: string|null,
146
- * recommend: boolean, reasons: string[] }}
265
+ * entriesSinceConsolidation: number|null, recommend: boolean,
266
+ * reasons: string[] }}
147
267
  */
148
268
  export function buildMemoryPoolAdvisory({
149
269
  cwd = process.cwd(),
@@ -152,15 +272,9 @@ export function buildMemoryPoolAdvisory({
152
272
  homedir,
153
273
  now = new Date(),
154
274
  staleAfterDays = STALE_AFTER_DAYS,
155
- entryCountCeiling = ENTRY_COUNT_CEILING,
275
+ growthDelta = GROWTH_DELTA,
156
276
  } = {}) {
157
- const absent = (reason) => ({
158
- present: false,
159
- entryCount: 0,
160
- lastConsolidatedAt: null,
161
- recommend: false,
162
- reasons: [reason],
163
- });
277
+ const absent = (reason) => envelope({ reasons: [reason] });
164
278
 
165
279
  const poolDir = resolveMemoryPoolDir({ cwd, env, homedir });
166
280
  if (!poolDir) {
@@ -184,48 +298,38 @@ export function buildMemoryPoolAdvisory({
184
298
  return absent(`memory pool at ${poolDir} could not be listed`);
185
299
  }
186
300
 
187
- const lastConsolidatedAt = readStamp({ poolDir, fsImpl });
188
- const reasons = [];
301
+ const stamp = readStamp({ poolDir, fsImpl });
302
+ // Reported raw: a pruning pass can leave this negative, and saying the pool
303
+ // shrank by 7 is more use to the operator than clamping it to zero.
304
+ const growth = stamp.baseline === null ? null : entryCount - stamp.baseline;
305
+
306
+ const found = {
307
+ present: true,
308
+ entryCount,
309
+ lastConsolidatedAt: stamp.at,
310
+ entriesSinceConsolidation: growth,
311
+ };
189
312
 
190
313
  // An empty pool has nothing to consolidate, whatever the stamp says.
191
314
  if (entryCount === 0) {
192
- return {
193
- present: true,
194
- entryCount: 0,
195
- lastConsolidatedAt,
196
- recommend: false,
315
+ return envelope({
316
+ ...found,
197
317
  reasons: ['memory pool is empty — nothing to consolidate'],
198
- };
318
+ });
199
319
  }
200
320
 
201
- if (lastConsolidatedAt === null) {
202
- reasons.push(
203
- 'no consolidation stamp — this pool has never been consolidated',
204
- );
205
- } else {
206
- const ageDays =
207
- (new Date(now).getTime() - Date.parse(lastConsolidatedAt)) / MS_PER_DAY;
208
- if (ageDays > staleAfterDays) {
209
- reasons.push(
210
- `last consolidated ${Math.floor(ageDays)} days ago (over the ${staleAfterDays}-day threshold)`,
211
- );
212
- }
213
- }
214
-
215
- if (entryCount > entryCountCeiling) {
216
- reasons.push(
217
- `${entryCount} entries (over the ${entryCountCeiling}-entry threshold)`,
218
- );
219
- }
321
+ const reasons = collectReasons({
322
+ stamp,
323
+ growth,
324
+ now,
325
+ staleAfterDays,
326
+ growthDelta,
327
+ });
220
328
 
221
- return {
222
- present: true,
223
- entryCount,
224
- lastConsolidatedAt,
329
+ return envelope({
330
+ ...found,
225
331
  recommend: reasons.length > 0,
226
332
  reasons:
227
- reasons.length > 0
228
- ? reasons
229
- : ['memory pool is within both freshness thresholds'],
230
- };
333
+ reasons.length > 0 ? reasons : [quietReason({ growth, growthDelta })],
334
+ });
231
335
  }
@@ -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,
@@ -79,6 +79,10 @@ import { createGateLogSink as defaultCreateGateLogSink } from '../gate-log.js';
79
79
  * runScopedFormatAutofix?: typeof defaultRunScopedFormatAutofix,
80
80
  * createGateLogSink?: typeof defaultCreateGateLogSink,
81
81
  * }} args
82
+ * @returns {Promise<{ gates: Record<string, 'passed'|'skipped'> }>} Per-gate
83
+ * outcomes keyed by gate name — the terminal envelope reports the split
84
+ * baselines entries from this (Story #5172). A failure throws instead, with
85
+ * `err.closeGate` naming the gate that died.
82
86
  */
83
87
  export async function runCloseValidationPhase({
84
88
  cwd,
@@ -144,17 +148,18 @@ export async function runCloseValidationPhase({
144
148
  // Story #4736 — one sink for both `log` seams (gate construction and gate
145
149
  // execution), so nothing in the chain can route around the artifact.
146
150
  const gateLog = createGateLogSink({ storyId, config });
151
+ const gateList = buildDefaultGates({
152
+ config,
153
+ baseBranch,
154
+ cwd: worktreePath || cwd,
155
+ log: gateLog.log,
156
+ });
147
157
  let validation;
148
158
  try {
149
159
  validation = await runCloseValidation({
150
160
  cwd,
151
161
  worktreePath,
152
- gates: buildDefaultGates({
153
- config,
154
- baseBranch,
155
- cwd: worktreePath || cwd,
156
- log: gateLog.log,
157
- }),
162
+ gates: gateList,
158
163
  log: gateLog.log,
159
164
  storyId,
160
165
  // Story #4250 — standalone storyId-anchored evidence keyspace. No
@@ -182,10 +187,37 @@ export async function runCloseValidationPhase({
182
187
  // The evidence is the point on this path: replay the captured tail inline
183
188
  // rather than making the caller open a file to learn why close stopped.
184
189
  gateLog.replay();
185
- throw new Error(
190
+ const err = new Error(
186
191
  `[single-story-close] Gate failed: ${gate.name} (exit ${status})${gateCwd ? ` in ${gateCwd}` : ''}.` +
187
192
  (gate.hint ? ` ${gate.hint}` : ''),
188
193
  );
194
+ // Story #5172 — the phase tracker tags `closePhase`; this tags WHICH gate
195
+ // inside the phase died, so the failed terminal can name the split
196
+ // baselines entry rather than reporting a generic validation failure.
197
+ err.closeGate = gate.name;
198
+ throw err;
189
199
  }
190
200
  progress('VALIDATE', `✅ All gates passed. ${gateLog.digest()}`);
201
+ return { gates: gateOutcomes(gateList, validation) };
202
+ }
203
+
204
+ /**
205
+ * Per-gate outcomes for a validation run that passed (Story #5172).
206
+ *
207
+ * Every registered gate passed unless the runner reported it skipped — an
208
+ * evidence short-circuit at unchanged HEAD, or a changed-file scope that
209
+ * matched nothing. `skipped` is the honest verdict for both: the gate did not
210
+ * run in THIS invocation, and the terminal schema's own contract is that a
211
+ * skipped gate is reported as skipped rather than quietly counted as a pass.
212
+ *
213
+ * @param {Array<{ name: string }>} gateList The gates this run registered.
214
+ * @param {{ skipped?: Array<{ gate: { name: string } }> }} validation
215
+ * @returns {Record<string, 'passed'|'skipped'>}
216
+ */
217
+ function gateOutcomes(gateList, validation) {
218
+ const outcomes = {};
219
+ for (const gate of gateList) outcomes[gate.name] = 'passed';
220
+ for (const { gate } of validation.skipped ?? [])
221
+ outcomes[gate.name] = 'skipped';
222
+ return outcomes;
191
223
  }
@@ -1,6 +1,9 @@
1
1
  import nodeFs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { buildDefaultGates } from '../../close-validation/gates.js';
3
+ import {
4
+ BASELINES_GATE_NAMES,
5
+ buildDefaultGates,
6
+ } from '../../close-validation/gates.js';
4
7
  import { runCloseValidation } from '../../close-validation/runner.js';
5
8
  import { getCiDelivery } from '../../config/ci.js';
6
9
  import { resolveConfig } from '../../config-resolver.js';
@@ -135,12 +138,57 @@ async function alreadyClosedResult(storyId, stateReason = null, config) {
135
138
  return { success: true, result, terminal };
136
139
  }
137
140
 
141
+ /**
142
+ * Project the baselines entries out of close-validation's per-gate outcomes,
143
+ * keyed by the gate's own name (Story #5172).
144
+ *
145
+ * The envelope's `gates` map used to roll the whole gate chain up into a
146
+ * single `validation` verdict, which was fine while the baselines gate was
147
+ * one entry and stopped being fine when it became two: a reader of a failed
148
+ * close could not tell whether the cheap coverage-independent baselines had
149
+ * breached or the expensive coverage-consuming ones had. Only registered
150
+ * entries are reported — a consumer whose config registers just one of the
151
+ * pair gets just that one, never a phantom key for a gate that never existed.
152
+ *
153
+ * @param {Record<string, string>|null|undefined} validationGates
154
+ * @returns {Record<string, string>}
155
+ */
156
+ function baselinesEnvelopeGates(validationGates) {
157
+ const registered = Object.values(BASELINES_GATE_NAMES);
158
+ const out = {};
159
+ for (const [name, outcome] of Object.entries(validationGates ?? {})) {
160
+ if (registered.includes(name)) out[name] = outcome;
161
+ }
162
+ return out;
163
+ }
164
+
138
165
  function resolveWorktreePath({ cwd, config, storyId }) {
139
166
  const root = config.delivery?.worktreeIsolation?.root ?? '.worktrees';
140
167
  const candidate = path.resolve(cwd, root, `story-${storyId}`);
141
168
  return nodeFs.existsSync(candidate) ? candidate : null;
142
169
  }
143
170
 
171
+ /**
172
+ * The pre-push phases, in the order the pipeline walks them: wrong-tree
173
+ * guard → base-sync → close-validation.
174
+ *
175
+ * Story #5172 put base-sync AHEAD of close-validation, for two reasons that
176
+ * are really one. The cheap one: a base-sync conflict is a hard block that
177
+ * costs nothing to detect, so paying for the full gate chain before
178
+ * discovering it burns the pipeline's most expensive minutes on a tree that
179
+ * was never going to be pushed. The load-bearing one: with the gates last,
180
+ * **the validated tree is the pushed tree**. Under the old order the merge
181
+ * commit base-sync writes landed AFTER validation, so every close pushed a
182
+ * tree no gate had ever seen.
183
+ *
184
+ * `--skip-sync` and `--skip-validation` stay independent — either, both or
185
+ * neither may be set, and each still elides exactly its own phase.
186
+ *
187
+ * @returns {Promise<{ validationGates: Record<string, string>|null }>}
188
+ * The per-gate outcomes close-validation observed, or `null` when the phase
189
+ * was skipped. Feeds the terminal envelope's `gates` map so the split
190
+ * baselines entries are separable there.
191
+ */
144
192
  async function runPrePushPhases({
145
193
  cwd,
146
194
  worktreePath,
@@ -166,22 +214,6 @@ async function runPrePushPhases({
166
214
  progress,
167
215
  gitSpawn: injectedGitSpawn,
168
216
  });
169
- if (!skipValidation) {
170
- setPhase('close-validation');
171
- await runCloseValidationPhase({
172
- cwd,
173
- worktreePath,
174
- config,
175
- baseBranch,
176
- storyBranch,
177
- storyId,
178
- progress,
179
- runCloseValidation,
180
- buildDefaultGates,
181
- });
182
- } else {
183
- progress('VALIDATE', '⏭ Skipped (--skip-validation).');
184
- }
185
217
  if (!skipSync) {
186
218
  setPhase('base-sync');
187
219
  await runBaseSyncPhase({
@@ -198,6 +230,23 @@ async function runPrePushPhases({
198
230
  } else {
199
231
  progress('SYNC', '⏭ Skipped (--skip-sync).');
200
232
  }
233
+ if (skipValidation) {
234
+ progress('VALIDATE', '⏭ Skipped (--skip-validation).');
235
+ return { validationGates: null };
236
+ }
237
+ setPhase('close-validation');
238
+ const validation = await runCloseValidationPhase({
239
+ cwd,
240
+ worktreePath,
241
+ config,
242
+ baseBranch,
243
+ storyBranch,
244
+ storyId,
245
+ progress,
246
+ runCloseValidation,
247
+ buildDefaultGates,
248
+ });
249
+ return { validationGates: validation?.gates ?? null };
201
250
  }
202
251
 
203
252
  async function openAndReviewPr({
@@ -743,7 +792,7 @@ async function runClosePipeline({
743
792
  config,
744
793
  injectedReleaseLease,
745
794
  };
746
- await releaseLeaseOnBlock(
795
+ const { validationGates } = await releaseLeaseOnBlock(
747
796
  () =>
748
797
  runPrePushPhases({
749
798
  ...options,
@@ -881,6 +930,9 @@ async function runClosePipeline({
881
930
  startedAtMs,
882
931
  gates: {
883
932
  validation: options.skipValidation ? 'skipped' : 'passed',
933
+ // Story #5172 — the split baselines entries, named individually so a
934
+ // reader can tell the two apart. Absent when validation was skipped.
935
+ ...baselinesEnvelopeGates(validationGates),
884
936
  baseSync: options.skipSync ? 'skipped' : 'passed',
885
937
  // An overridden blocker reports `overridden`, never
886
938
  // `passed`. The review DID fail; a human authorized shipping anyway, and