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.
- package/.agents/agents/story-worker.md +24 -14
- package/.agents/docs/agentrc-reference.json +7 -2
- package/.agents/docs/configuration.md +5 -2
- package/.agents/schemas/agentrc.schema.json +17 -2
- 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/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/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/docs/CHANGELOG.md +18 -0
- package/package.json +1 -1
|
@@ -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
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* verify-credit.js — decide whether a Story `verify[]` entry has already been
|
|
3
|
+
* paid for by the delivery's single credited full-suite run (Story #5174).
|
|
4
|
+
*
|
|
5
|
+
* A Story's `verify[]` is meant to be *scoped* entries plus the one credited
|
|
6
|
+
* full-suite run the worker makes just before the hand-off push
|
|
7
|
+
* (`helpers/deliver-digest.md` § 5). When a `verify[]` entry is itself a
|
|
8
|
+
* full-suite command, running it spends a second whole-suite spawn for a
|
|
9
|
+
* result the credited run already established — and the close gate chain then
|
|
10
|
+
* makes a third. This module is the read side of that credit: given the
|
|
11
|
+
* entry's command it consults **the same stamp close consults** and reports
|
|
12
|
+
* the entry as credited instead of telling the caller to spawn it.
|
|
13
|
+
*
|
|
14
|
+
* It only ever *reads*. Nothing here writes a capture stamp or an evidence
|
|
15
|
+
* record — an entry that is not covered by a fresh stamp is reported
|
|
16
|
+
* `spawn: true` and runs for real, so the credit can never manufacture a pass.
|
|
17
|
+
*
|
|
18
|
+
* @see .agents/scripts/lib/coverage-capture.js (`isCoverageFresh`)
|
|
19
|
+
* @see .agents/scripts/lib/validation-evidence.js (`shouldSkip`)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { getQuality, resolveConfig } from '../config-resolver.js';
|
|
23
|
+
import { isCoverageFresh } from '../coverage-capture.js';
|
|
24
|
+
import { gitSpawn } from '../git-utils.js';
|
|
25
|
+
import { hasNpmScript, readPackageScripts } from '../npm-scripts.js';
|
|
26
|
+
import { hashCommandConfig, shouldSkip } from '../validation-evidence.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The shape a `verify[]` array is supposed to have, stated once so the
|
|
30
|
+
* warning a caller surfaces and the prose in `deliver-digest.md` § 5 say the
|
|
31
|
+
* same thing.
|
|
32
|
+
* @type {string}
|
|
33
|
+
*/
|
|
34
|
+
export const FULL_SUITE_SHAPE_WARNING =
|
|
35
|
+
'verify[] should be scoped entries plus the single credited full-suite run ' +
|
|
36
|
+
'(deliver-digest.md § 5) — a full-suite command listed in verify[] is ' +
|
|
37
|
+
'reported credited against that run, never respawned.';
|
|
38
|
+
|
|
39
|
+
/** Package managers whose `test` script means "the whole suite". */
|
|
40
|
+
const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun']);
|
|
41
|
+
|
|
42
|
+
/** Script names that mean "the whole suite" rather than a scoped subset. */
|
|
43
|
+
const FULL_SUITE_SCRIPTS = new Set(['test', 'test:coverage']);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Split a Story `verify[]` line into its command and its tier tag.
|
|
47
|
+
*
|
|
48
|
+
* Story bodies write entries as `` `<command>` (<tier>) `` — the tier is
|
|
49
|
+
* planning metadata, not part of the command, and leaving it attached would
|
|
50
|
+
* make every entry look scoped.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} entry
|
|
53
|
+
* @returns {{ command: string, tier: string|null }}
|
|
54
|
+
*/
|
|
55
|
+
export function parseVerifyEntry(entry) {
|
|
56
|
+
const text = String(entry ?? '').trim();
|
|
57
|
+
const tagged = /^(.*?)\s*\(([a-z-]+)\)$/i.exec(text);
|
|
58
|
+
const command = (tagged ? tagged[1] : text).trim().replace(/^`|`$/g, '');
|
|
59
|
+
return { command: command.trim(), tier: tagged ? tagged[2] : null };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Is this command a whole-suite run?
|
|
64
|
+
*
|
|
65
|
+
* Deliberately narrow. A false positive here would report a *scoped* command
|
|
66
|
+
* as credited without ever running it, which is how a gate stops gating — so
|
|
67
|
+
* anything carrying its own positional argument (`npm test -- tests/x.js`,
|
|
68
|
+
* `node --test tests/x.js`) is scoped by construction.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} command
|
|
71
|
+
* @returns {boolean}
|
|
72
|
+
*/
|
|
73
|
+
export function isFullSuiteCommand(command) {
|
|
74
|
+
const tokens = String(command ?? '')
|
|
75
|
+
.trim()
|
|
76
|
+
.split(/\s+/)
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
if (tokens.length === 0) return false;
|
|
79
|
+
|
|
80
|
+
if (tokens[0] === 'node') {
|
|
81
|
+
// `node --test` with no path argument walks the default test globs.
|
|
82
|
+
const rest = tokens.slice(1);
|
|
83
|
+
return rest.length > 0 && rest.every((t) => t.startsWith('-'));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!PACKAGE_MANAGERS.has(tokens[0])) return false;
|
|
87
|
+
const rest = tokens[1] === 'run' ? tokens.slice(2) : tokens.slice(1);
|
|
88
|
+
if (rest.length === 0 || !FULL_SUITE_SCRIPTS.has(rest[0])) return false;
|
|
89
|
+
// `npm test -- <path>` narrows the run; only a bare invocation is the suite.
|
|
90
|
+
return rest.length === 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Read HEAD from a worktree. `null` when the tree cannot be read — which
|
|
95
|
+
* routes to `spawn`, never to a credit.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} cwd
|
|
98
|
+
* @param {Function} gitSpawnFn
|
|
99
|
+
* @returns {string|null}
|
|
100
|
+
*/
|
|
101
|
+
function readHeadSha(cwd, gitSpawnFn) {
|
|
102
|
+
const res = gitSpawnFn(cwd, 'rev-parse', 'HEAD');
|
|
103
|
+
if (res?.status !== 0) return null;
|
|
104
|
+
const sha = String(res.stdout ?? '').trim();
|
|
105
|
+
return sha.length > 0 ? sha : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Decide how a single `verify[]` entry should be executed.
|
|
110
|
+
*
|
|
111
|
+
* @param {object} input
|
|
112
|
+
* @param {string} input.command — the entry's command (tier tag already off).
|
|
113
|
+
* @param {number|string} input.storyId
|
|
114
|
+
* @param {string} input.worktree — ABSOLUTE path to the Story worktree.
|
|
115
|
+
* @param {string} [input.cwd] — main checkout (evidence keyspace root).
|
|
116
|
+
* Defaults to `worktree`.
|
|
117
|
+
* @param {object} [deps] — test seams; every one defaults to the real impl.
|
|
118
|
+
* @returns {{
|
|
119
|
+
* command: string, fullSuite: boolean, credited: boolean, spawn: boolean,
|
|
120
|
+
* mode: 'capture'|'evidence'|null, reason: string, warning: string|null
|
|
121
|
+
* }}
|
|
122
|
+
*/
|
|
123
|
+
export function resolveVerifyCredit(
|
|
124
|
+
{ command, storyId, worktree, cwd = worktree },
|
|
125
|
+
deps = {},
|
|
126
|
+
) {
|
|
127
|
+
const {
|
|
128
|
+
resolveConfigImpl = resolveConfig,
|
|
129
|
+
getQualityImpl = getQuality,
|
|
130
|
+
readPackageScriptsImpl = readPackageScripts,
|
|
131
|
+
hasNpmScriptImpl = hasNpmScript,
|
|
132
|
+
isCoverageFreshImpl = isCoverageFresh,
|
|
133
|
+
shouldSkipImpl = shouldSkip,
|
|
134
|
+
hashCommandConfigImpl = hashCommandConfig,
|
|
135
|
+
gitSpawnFn = gitSpawn,
|
|
136
|
+
} = deps;
|
|
137
|
+
|
|
138
|
+
const base = { command, fullSuite: false, mode: null, warning: null };
|
|
139
|
+
if (!isFullSuiteCommand(command)) {
|
|
140
|
+
return { ...base, credited: false, spawn: true, reason: 'scoped' };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const scoped = {
|
|
144
|
+
...base,
|
|
145
|
+
fullSuite: true,
|
|
146
|
+
warning: FULL_SUITE_SHAPE_WARNING,
|
|
147
|
+
};
|
|
148
|
+
const { crap } = getQualityImpl(resolveConfigImpl({ cwd: worktree }));
|
|
149
|
+
const mode =
|
|
150
|
+
crap?.enabled !== false &&
|
|
151
|
+
hasNpmScriptImpl(readPackageScriptsImpl(worktree), 'test:coverage')
|
|
152
|
+
? 'capture'
|
|
153
|
+
: 'evidence';
|
|
154
|
+
|
|
155
|
+
if (mode === 'capture') {
|
|
156
|
+
const freshness = isCoverageFreshImpl({
|
|
157
|
+
coveragePath: crap.coveragePath,
|
|
158
|
+
targetDirs: crap.targetDirs,
|
|
159
|
+
cwd: worktree,
|
|
160
|
+
});
|
|
161
|
+
const fresh = freshness?.fresh === true;
|
|
162
|
+
return {
|
|
163
|
+
...scoped,
|
|
164
|
+
mode,
|
|
165
|
+
credited: fresh,
|
|
166
|
+
spawn: !fresh,
|
|
167
|
+
reason: fresh ? 'capture-stamp-fresh' : (freshness?.reason ?? 'unknown'),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const headSha = readHeadSha(worktree, gitSpawnFn);
|
|
172
|
+
if (!headSha) {
|
|
173
|
+
return { ...scoped, mode, credited: false, spawn: true, reason: 'no-head' };
|
|
174
|
+
}
|
|
175
|
+
const [cmd, ...args] = command.split(/\s+/).filter(Boolean);
|
|
176
|
+
const verdict = shouldSkipImpl(
|
|
177
|
+
{
|
|
178
|
+
storyId,
|
|
179
|
+
gateName: 'test',
|
|
180
|
+
currentSha: headSha,
|
|
181
|
+
configHash: hashCommandConfigImpl({ cmd, args, cwd: worktree }),
|
|
182
|
+
},
|
|
183
|
+
{ cwd, standalone: true },
|
|
184
|
+
);
|
|
185
|
+
return {
|
|
186
|
+
...scoped,
|
|
187
|
+
mode,
|
|
188
|
+
credited: verdict.skip === true,
|
|
189
|
+
spawn: verdict.skip !== true,
|
|
190
|
+
reason: verdict.reason,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Classify a whole `verify[]` array in one pass.
|
|
196
|
+
*
|
|
197
|
+
* @param {string[]} entries — raw `verify[]` lines, tier tags included.
|
|
198
|
+
* @param {{ storyId: number|string, worktree: string, cwd?: string }} context
|
|
199
|
+
* @param {object} [deps]
|
|
200
|
+
* @returns {Array<ReturnType<typeof resolveVerifyCredit> & { tier: string|null }>}
|
|
201
|
+
*/
|
|
202
|
+
export function planVerifyExecution(entries, context, deps = {}) {
|
|
203
|
+
return (Array.isArray(entries) ? entries : []).map((entry) => {
|
|
204
|
+
const { command, tier } = parseVerifyEntry(entry);
|
|
205
|
+
return { ...resolveVerifyCredit({ ...context, command }, deps), tier };
|
|
206
|
+
});
|
|
207
|
+
}
|
|
@@ -161,6 +161,30 @@ function readLockOwner(lockPath, fsImpl = fs) {
|
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Read the pid a lockfile was created by (its third line — see
|
|
166
|
+
* {@link tryCreateLock}'s body format). Returns `null` when the file is
|
|
167
|
+
* absent, unreadable, or its pid line is not a positive integer.
|
|
168
|
+
*
|
|
169
|
+
* Exists so a *waiting* caller can name the holder in its wait line: a bounded
|
|
170
|
+
* wait with no attribution is indistinguishable from a hang, and the pid is
|
|
171
|
+
* the one field an operator can act on (`ps`, `kill`). Reading it is
|
|
172
|
+
* advisory — a `null` just means the wait line says less.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} lockPath
|
|
175
|
+
* @param {object} [fsImpl]
|
|
176
|
+
* @returns {number|null}
|
|
177
|
+
*/
|
|
178
|
+
export function readLockHolderPid(lockPath, fsImpl = fs) {
|
|
179
|
+
try {
|
|
180
|
+
const lines = String(fsImpl.readFileSync(lockPath, 'utf8')).split('\n');
|
|
181
|
+
const pid = Number.parseInt(lines[2] ?? '', 10);
|
|
182
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
164
188
|
/**
|
|
165
189
|
* Pure: do two identity tuples describe the same lockfile instance? A `null`
|
|
166
190
|
* on either side is "not the same" — an absent file is never the file we
|
|
@@ -113,6 +113,18 @@ mid-delivery, and evaluates the actual work product.
|
|
|
113
113
|
optional advisory pre-flight — a criterion cannot be scored `met` without
|
|
114
114
|
the supporting `verify[]` evidence where a `verify[]` command is relevant
|
|
115
115
|
to it.
|
|
116
|
+
- **Reuses the credited full-suite run instead of re-paying for it.**
|
|
117
|
+
Before spawning a `verify[]` entry, classify it with `resolveVerifyCredit`
|
|
118
|
+
from
|
|
119
|
+
[`verify-credit.js`](../../scripts/lib/orchestration/verify-credit.js): an
|
|
120
|
+
entry that is itself a full-suite command (`npm test`, `pnpm run test`,
|
|
121
|
+
a bare `node --test`) is consulted against the **same stamp close reads**
|
|
122
|
+
and, when that stamp is fresh, recorded as `pass` with a `detail` naming
|
|
123
|
+
the credit — **never respawned**. A stale or absent stamp reports
|
|
124
|
+
`spawn: true` and the command runs for real, so the credit can never
|
|
125
|
+
manufacture a pass. The gate warns on any such entry: the intended shape
|
|
126
|
+
is scoped `verify[]` entries **plus** the one credited run
|
|
127
|
+
([`deliver-digest.md`](deliver-digest.md) § 5).
|
|
116
128
|
- **Shares `lint` / `typecheck` evidence with close.** When a
|
|
117
129
|
`verify[]` command is **byte-identical** to a close-validation gate — in
|
|
118
130
|
practice only the cheap, command-identical `lint` and `typecheck` gates
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
description: >-
|
|
3
3
|
The deliver path's one bundled framework read. Carries what
|
|
4
4
|
every Story delivery always needs — dispatch decision, engine invariants,
|
|
5
|
-
the change-set/ceremony incantation, the acceptance-eval gate,
|
|
6
|
-
terminal envelope contract — so the engine reads one
|
|
7
|
-
re-reading the helper/schema set each session.
|
|
5
|
+
the change-set/ceremony incantation, the acceptance-eval gate, the credited
|
|
6
|
+
full-suite run, and the terminal envelope contract — so the engine reads one
|
|
7
|
+
file instead of re-reading the helper/schema set each session.
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# Deliver digest (read once per session)
|
|
@@ -27,10 +27,8 @@ rule produces it:
|
|
|
27
27
|
whatever its shape — sub-agent isolation is load-bearing only against a
|
|
28
28
|
*concurrent* sibling racing the same checkout, and a one-Story run has none.
|
|
29
29
|
2. **Every other run is `subagent`.** A multi-Story run dispatches every Story
|
|
30
|
-
as a sub-agent however trivial its shape
|
|
31
|
-
|
|
32
|
-
on one beat. Shape still sets ceremony; the `route::lite` label is a
|
|
33
|
-
human-visible hint, never the control signal.
|
|
30
|
+
as a sub-agent however trivial its shape. Shape still sets ceremony; the
|
|
31
|
+
`route::lite` label is a human-visible hint, never the control signal.
|
|
34
32
|
|
|
35
33
|
`inline` removes model-side fan-out only — no `story-worker` boot, no fresh
|
|
36
34
|
acceptance-critic spawn. **`subagent` and `inline` run the same engine**: same
|
|
@@ -112,7 +110,30 @@ an unmerged cluster verdict scores a fraction of the criteria and still reports
|
|
|
112
110
|
not close**: post a `friction` comment and flip `agent::blocked`.
|
|
113
111
|
Per-round mechanics: [`acceptance-self-eval.md`](acceptance-self-eval.md).
|
|
114
112
|
|
|
115
|
-
## 5.
|
|
113
|
+
## 5. The one creditable full-suite run
|
|
114
|
+
|
|
115
|
+
**After the self-eval loop's last fix commit, immediately before the push** —
|
|
116
|
+
the credit is keyed on the tree, so any later commit invalidates it. Redraft
|
|
117
|
+
rounds run scoped tests; only this final run needs credit, and a bare
|
|
118
|
+
`npm test` / `pnpm run test` deposits **none**, so close re-runs the identical
|
|
119
|
+
suite. Shape it by the predicate `close-validation/gates.js` uses for its test
|
|
120
|
+
gate:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
# CRAP gate on (default) + a `test:coverage` script — writes the stamp the
|
|
124
|
+
# close `coverage-capture` gate reads:
|
|
125
|
+
node <main-repo>/.agents/scripts/coverage-capture.js --cwd <workCwd>
|
|
126
|
+
# otherwise — the record the close `test` gate reads. <workCwd> ABSOLUTE,
|
|
127
|
+
# runner exactly `npm test`: both sides hash {cmd, args, cwd}.
|
|
128
|
+
node <main-repo>/.agents/scripts/evidence-gate.js --standalone \
|
|
129
|
+
--scope-id <storyId> --gate test --worktree <workCwd> -- npm test
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`verify[]` is scoped entries **plus** this one run: an entry that is itself a
|
|
133
|
+
full-suite command is reported credited against the same stamp, never
|
|
134
|
+
respawned.
|
|
135
|
+
|
|
136
|
+
## 6. Terminal envelope — the return contract
|
|
116
137
|
|
|
117
138
|
`single-story-close.js` emits exactly one envelope on stdout between
|
|
118
139
|
`--- STORY DELIVER TERMINAL ---` markers, schema-validated against
|
|
@@ -129,7 +150,7 @@ Relay it verbatim; never hand-compose one, never substitute prose.
|
|
|
129
150
|
|
|
130
151
|
Required fields: `kind` (`story-deliver-terminal`), `storyId`, `status`,
|
|
131
152
|
`phase`, `elapsedSeconds`, `nextCommand`. `phase` is one of `init`,
|
|
132
|
-
`wrong-tree-guard`, `
|
|
153
|
+
`wrong-tree-guard`, `base-sync`, `close-validation`, `push`, `pull-request`,
|
|
133
154
|
`code-review`, `auto-merge`, `confirm-merge`, `post-land`, `done`. `gates`
|
|
134
155
|
reports every gate as `passed` / `failed` / `skipped` — a skipped gate is
|
|
135
156
|
reported, never omitted, so a missing gate is never read as a passing one.
|
|
@@ -139,7 +160,7 @@ reported, never omitted, so a missing gate is never read as a passing one.
|
|
|
139
160
|
success; a failed gate replays its tail inline. `AGENT_LOG_LEVEL=verbose`
|
|
140
161
|
restores live streaming.
|
|
141
162
|
|
|
142
|
-
##
|
|
163
|
+
## 7. When to leave this file
|
|
143
164
|
|
|
144
165
|
- Unclear state / a re-run refusal → `deliver-recover.js --story <id>` (read-only).
|
|
145
166
|
- Lease, sweep, worktree-scope detail → [`deliver-story-reference.md`](deliver-story-reference.md).
|