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.
- package/.agents/agents/story-worker.md +24 -14
- package/.agents/docs/agentrc-reference.json +11 -2
- package/.agents/docs/configuration.md +9 -3
- package/.agents/docs/workflows.md +1 -1
- package/.agents/schemas/agentrc.schema.json +37 -3
- package/.agents/schemas/validation-evidence.schema.json +3 -1
- package/.agents/scripts/acceptance-eval.js +68 -3
- package/.agents/scripts/coverage-capture.js +25 -8
- package/.agents/scripts/lib/baselines/crap-preview-incremental.js +7 -2
- package/.agents/scripts/lib/baselines/git-base.js +74 -38
- package/.agents/scripts/lib/close-validation/gates.js +153 -25
- package/.agents/scripts/lib/close-validation/process.js +30 -1
- package/.agents/scripts/lib/close-validation/runner.js +5 -0
- package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +33 -12
- package/.agents/scripts/lib/config/quality.js +36 -21
- package/.agents/scripts/lib/config-settings-schema-delivery.js +6 -0
- package/.agents/scripts/lib/config-settings-schema.js +29 -1
- package/.agents/scripts/lib/coverage-capture-incremental.js +12 -6
- package/.agents/scripts/lib/crap-baseline-join.js +11 -7
- package/.agents/scripts/lib/full-suite-lock.js +311 -0
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +11 -104
- package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +320 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
- package/.agents/scripts/lib/orchestration/plan-context.js +4 -0
- package/.agents/scripts/lib/orchestration/planning/authoring-context.js +9 -1
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +159 -55
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +83 -4
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +39 -7
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +70 -18
- package/.agents/scripts/lib/orchestration/verify-credit.js +207 -0
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +24 -0
- package/.agents/workflows/helpers/acceptance-self-eval.md +12 -0
- package/.agents/workflows/helpers/deliver-digest.md +31 -10
- package/.agents/workflows/helpers/deliver-story-reference.md +50 -30
- package/.agents/workflows/helpers/deliver-story.md +23 -21
- package/.agents/workflows/memory-consolidate.md +18 -6
- package/docs/CHANGELOG.md +25 -0
- 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
|
|
44
|
-
const
|
|
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
|
-
*
|
|
98
|
-
*
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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.
|
|
263
|
+
* @param {number} [opts.growthDelta]
|
|
145
264
|
* @returns {{ present: boolean, entryCount: number, lastConsolidatedAt: string|null,
|
|
146
|
-
*
|
|
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
|
-
|
|
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
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
223
|
-
entryCount,
|
|
224
|
-
lastConsolidatedAt,
|
|
329
|
+
return envelope({
|
|
330
|
+
...found,
|
|
225
331
|
recommend: reasons.length > 0,
|
|
226
332
|
reasons:
|
|
227
|
-
reasons.length > 0
|
|
228
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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 {
|
|
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
|