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
@@ -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
@@ -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, and the
6
- terminal envelope contract — so the engine reads one file instead of
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 a lite body does not conjure a
31
- second session for a sibling, and the wave tick may hand you the whole set
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. Terminal envelope the return contract
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`, `close-validation`, `base-sync`, `push`, `pull-request`,
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
- ## 6. When to leave this file
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).