mandrel 2.19.0 → 2.21.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 (37) hide show
  1. package/.agents/agents/story-worker.md +10 -0
  2. package/.agents/docs/agentrc-reference.json +0 -21
  3. package/.agents/docs/configuration.md +11 -14
  4. package/.agents/docs/execution-reference.md +8 -5
  5. package/.agents/schemas/agentrc.schema.json +0 -31
  6. package/.agents/scripts/check-test-temp-hygiene.js +153 -14
  7. package/.agents/scripts/deliver-light.js +72 -8
  8. package/.agents/scripts/lib/baselines/kinds/maintainability.js +3 -4
  9. package/.agents/scripts/lib/bdd-scenario-scanner.js +3 -2
  10. package/.agents/scripts/lib/config/explain.js +0 -8
  11. package/.agents/scripts/lib/config/temp-paths.js +74 -1
  12. package/.agents/scripts/lib/config-settings-schema.js +8 -24
  13. package/.agents/scripts/lib/orchestration/complexity-gate.js +68 -6
  14. package/.agents/scripts/lib/orchestration/deliver-recover.js +253 -6
  15. package/.agents/scripts/lib/orchestration/file-assumptions.js +4 -2
  16. package/.agents/scripts/lib/orchestration/light-suitability.js +194 -11
  17. package/.agents/scripts/lib/orchestration/plan-context.js +13 -14
  18. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +12 -66
  19. package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +11 -1
  20. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +1 -1
  21. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +117 -4
  22. package/.agents/scripts/lib/temp-retention.js +23 -8
  23. package/.agents/scripts/lib/test-env.js +15 -3
  24. package/.agents/scripts/lib/test-temp.js +311 -0
  25. package/.agents/scripts/single-story-confirm-merge.js +1 -1
  26. package/.agents/workflows/helpers/deliver-light.md +45 -5
  27. package/.agents/workflows/helpers/deliver-story-reference.md +33 -0
  28. package/.agents/workflows/helpers/deliver-story.md +6 -3
  29. package/.agents/workflows/helpers/plan-reference.md +19 -4
  30. package/.agents/workflows/plan.md +7 -6
  31. package/docs/CHANGELOG.md +16 -0
  32. package/lib/migrations/index.js +2 -0
  33. package/lib/migrations/steps/2.20.0-retire-codebase-snapshot.js +113 -0
  34. package/package.json +1 -1
  35. package/.agents/scripts/lib/codebase-snapshot.js +0 -513
  36. package/.agents/scripts/lib/orchestration/planning/spec-authoring-grounding.js +0 -147
  37. package/.agents/scripts/lib/orchestration/spec-freshness.js +0 -129
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Managed test temp directories (Story #4808).
3
+ *
4
+ * The suite used to mint `os.tmpdir()` directories directly at ~71 call
5
+ * sites across 25 files that never reaped them, accumulating tens of
6
+ * thousands of entries per run into a temp root shared with self-hosted CI
7
+ * runners. The damaging axis is **entry count**, not bytes: a runner's
8
+ * job-started hook scanning an 841k-entry temp root burned 5m29s inside the
9
+ * job clock and timed jobs out.
10
+ *
11
+ * Per-call-site teardown had already failed 25 times, so this module makes
12
+ * teardown structural instead: a directory cannot be created without its
13
+ * reaping already registered.
14
+ *
15
+ * ## Why one suite root
16
+ *
17
+ * A guard over a *shared* `os.tmpdir()` cannot attribute an entry to this
18
+ * suite. A prefix allowlist rots the moment someone invents a new prefix,
19
+ * and a bare "no new entries" assertion false-positives on any unrelated
20
+ * process that happened to run concurrently. Nesting every managed
21
+ * directory inside a single per-process root
22
+ * (`mandrel-suite-<pid>-<random>`) makes attribution exact: the guard asks
23
+ * only whether a *suite root* survived, which is a question about this
24
+ * suite alone. The suite contributes exactly one shared-root entry per
25
+ * process, and reaps it.
26
+ *
27
+ * ## Why the root is never published to children
28
+ *
29
+ * Deliberately unlike `MANDREL_TEST_TEMP_ROOT` (the scratch seam in
30
+ * `test-env.js` / `config/temp-paths.js`, which children inherit): each
31
+ * process owns its own suite root, so "did I create it?" is always
32
+ * answerable locally and a child can never reap its parent's root
33
+ * mid-run.
34
+ */
35
+
36
+ import fs from 'node:fs';
37
+ import os from 'node:os';
38
+ import path from 'node:path';
39
+ import picomatch from 'picomatch';
40
+
41
+ /**
42
+ * Directory-name prefix identifying a per-process suite root. The guard
43
+ * matches on this, so it is the one string both sides must agree on.
44
+ */
45
+ export const SUITE_ROOT_PREFIX = 'mandrel-suite-';
46
+
47
+ /**
48
+ * Reserved snapshot-manifest key under which the guard records the suite
49
+ * roots observed at `--snapshot` time. It cannot collide with a stream
50
+ * entry: those are always `*.ndjson` relative paths.
51
+ */
52
+ export const SUITE_ROOTS_KEY = '#suiteRoots';
53
+
54
+ /** Per-process suite root, or `null` before the first `makeTempDir`. */
55
+ let _suiteRoot = null;
56
+
57
+ /** Guards against registering the exit reaper more than once. */
58
+ let _reaperRegistered = false;
59
+
60
+ /**
61
+ * Test-only: forget the per-process suite root without removing it, so a
62
+ * test can exercise the creation branch repeatedly in one process.
63
+ */
64
+ export function _resetSuiteTempRootForTests() {
65
+ _suiteRoot = null;
66
+ _reaperRegistered = false;
67
+ }
68
+
69
+ /**
70
+ * Test-only: report whether this process currently owns a suite root.
71
+ * @returns {string|null}
72
+ */
73
+ export function _currentSuiteTempRoot() {
74
+ return _suiteRoot;
75
+ }
76
+
77
+ /**
78
+ * Remove this process's suite root and everything under it.
79
+ *
80
+ * A teardown failure is reported on stderr and swallowed: a suite that
81
+ * passed must not start failing because a directory could not be unlinked
82
+ * (a Windows file lock, a read-only mount). The leak is the lesser defect
83
+ * and the guard reports it separately.
84
+ *
85
+ * Only the process that minted the root can reach a non-null `_suiteRoot`,
86
+ * so this is creator-only by construction.
87
+ *
88
+ * @param {{ fsImpl?: typeof fs, warn?: (msg: string) => void }} [deps]
89
+ * @returns {string|null} the removed root, or `null` when there was none
90
+ */
91
+ export function reapSuiteTempRoot({
92
+ fsImpl = fs,
93
+ warn = (msg) => process.stderr.write(`${msg}\n`),
94
+ } = {}) {
95
+ const root = _suiteRoot;
96
+ if (root === null) return null;
97
+ _suiteRoot = null;
98
+ try {
99
+ fsImpl.rmSync(root, { recursive: true, force: true });
100
+ } catch (err) {
101
+ warn(`[test-temp] failed to reap suite temp root ${root}: ${err.message}`);
102
+ }
103
+ return root;
104
+ }
105
+
106
+ /**
107
+ * Resolve (creating on first use) this process's suite root.
108
+ *
109
+ * The reaper is registered on `exit` at creation time, so a directory
110
+ * cannot exist without its teardown already armed — including when the
111
+ * suite fails, since a failing `node --test` run still exits normally.
112
+ *
113
+ * @param {{ fsImpl?: typeof fs, tmpdir?: () => string, onExit?: (fn: () => void) => void }} [deps]
114
+ * @returns {string} absolute path to the suite root
115
+ */
116
+ export function suiteTempRoot({
117
+ fsImpl = fs,
118
+ tmpdir = os.tmpdir,
119
+ onExit = (fn) => process.once('exit', fn),
120
+ } = {}) {
121
+ if (_suiteRoot !== null) return _suiteRoot;
122
+ _suiteRoot = fsImpl.mkdtempSync(
123
+ path.join(tmpdir(), `${SUITE_ROOT_PREFIX}${process.pid}-`),
124
+ );
125
+ if (!_reaperRegistered) {
126
+ _reaperRegistered = true;
127
+ onExit(() => reapSuiteTempRoot({ fsImpl }));
128
+ }
129
+ return _suiteRoot;
130
+ }
131
+
132
+ /**
133
+ * Create a fresh temp directory for a test, nested inside this process's
134
+ * suite root and reaped with it.
135
+ *
136
+ * Drop-in for `mkdtempSync(path.join(os.tmpdir(), prefix))` — the returned
137
+ * path is absolute and unique, so call sites change only where the
138
+ * directory comes from, never how it is used.
139
+ *
140
+ * @param {string} [prefix='t-'] label kept for readability in a stack trace
141
+ * @param {{ fsImpl?: typeof fs, tmpdir?: () => string, onExit?: (fn: () => void) => void }} [deps]
142
+ * @returns {string} absolute path to the new directory
143
+ */
144
+ export function makeTempDir(prefix = 't-', deps = {}) {
145
+ const root = suiteTempRoot(deps);
146
+ const fsImpl = deps.fsImpl ?? fs;
147
+ return fsImpl.mkdtempSync(path.join(root, prefix));
148
+ }
149
+
150
+ /**
151
+ * Register removal of one specific scratch directory at process exit.
152
+ *
153
+ * For the two scratch seams (`test-env.js`, `config/temp-paths.js`) that
154
+ * mint a root *outside* the suite tree because children inherit its path
155
+ * through `MANDREL_TEST_TEMP_ROOT`. Call this only from the branch that
156
+ * actually minted the directory — a process that inherited the path must
157
+ * never reap it, or it deletes its parent's scratch mid-run.
158
+ *
159
+ * Teardown failures are swallowed for the same reason as
160
+ * {@link reapSuiteTempRoot}: a leak must not turn a passing suite red.
161
+ *
162
+ * @param {string} dirPath absolute path this process minted
163
+ * @param {{ fsImpl?: typeof fs, onExit?: (fn: () => void) => void, warn?: (msg: string) => void }} [deps]
164
+ * @returns {void}
165
+ */
166
+ export function reapOnExit(
167
+ dirPath,
168
+ {
169
+ fsImpl = fs,
170
+ onExit = (fn) => process.once('exit', fn),
171
+ warn = (msg) => process.stderr.write(`${msg}\n`),
172
+ } = {},
173
+ ) {
174
+ onExit(() => {
175
+ try {
176
+ fsImpl.rmSync(dirPath, { recursive: true, force: true });
177
+ } catch (err) {
178
+ warn(`[test-temp] failed to reap scratch dir ${dirPath}: ${err.message}`);
179
+ }
180
+ });
181
+ }
182
+
183
+ /**
184
+ * List the suite roots currently present in `tmpDir`, sorted.
185
+ *
186
+ * Names only (not absolute paths) so the guard can diff them against a
187
+ * recorded snapshot without embedding the temp root's absolute location.
188
+ *
189
+ * @param {string} tmpDir
190
+ * @param {{ fsImpl?: typeof fs }} [deps]
191
+ * @returns {string[]}
192
+ */
193
+ export function listSuiteTempRoots(tmpDir, { fsImpl = fs } = {}) {
194
+ if (!fsImpl.existsSync(tmpDir)) return [];
195
+ return fsImpl
196
+ .readdirSync(tmpDir, { withFileTypes: true })
197
+ .filter(
198
+ (ent) => ent.isDirectory() && ent.name.startsWith(SUITE_ROOT_PREFIX),
199
+ )
200
+ .map((ent) => ent.name)
201
+ .sort();
202
+ }
203
+
204
+ /**
205
+ * Suite roots that appeared since the snapshot and are still on disk —
206
+ * i.e. roots this suite run created and failed to reap.
207
+ *
208
+ * Diffing against the snapshot rather than asserting an empty set is what
209
+ * keeps a concurrently-running suite (another checkout, another worktree)
210
+ * from failing this one.
211
+ *
212
+ * @param {string} tmpDir
213
+ * @param {string[]} snapshotRoots
214
+ * @param {{ fsImpl?: typeof fs }} [deps]
215
+ * @returns {string[]}
216
+ */
217
+ export function survivingSuiteTempRoots(tmpDir, snapshotRoots, deps = {}) {
218
+ const known = new Set(snapshotRoots ?? []);
219
+ return listSuiteTempRoots(tmpDir, deps).filter((name) => !known.has(name));
220
+ }
221
+
222
+ /**
223
+ * Matches a `mkdtemp` / `mkdtempSync` call whose argument reaches
224
+ * `tmpdir()`. The lookahead spans the call's argument text rather than
225
+ * trying to balance parentheses, so it catches every shape in use:
226
+ * `mkdtempSync(path.join(os.tmpdir(), 'x-'))`, `mkdtempSync(join(tmpdir(),
227
+ * 'x-'))`, and `fs.mkdtempSync(...)`.
228
+ */
229
+ const RAW_TMPDIR_MKDTEMP =
230
+ /mkdtemp(?:Sync)?\s*\([^;\n]{0,200}?tmpdir\s*\(\s*\)/;
231
+
232
+ /**
233
+ * Opt-out marker for a line that must call `mkdtemp` against the real OS
234
+ * temp root — the guard's own tests, and the scratch seams that
235
+ * deliberately mint a root outside the suite tree.
236
+ */
237
+ const LINT_ESCAPE = 'test-temp-allow';
238
+
239
+ /**
240
+ * Flag test files that mint OS temp directories directly instead of going
241
+ * through {@link makeTempDir}.
242
+ *
243
+ * This is the half of the backstop that catches the *next* leaking file at
244
+ * authoring time rather than after it has already leaked, so it is scoped
245
+ * to explicitly-passed globs: `check-test-temp-hygiene.js` ships in the
246
+ * materialized `.agents/` payload, and a consumer's tests are none of this
247
+ * rule's business.
248
+ *
249
+ * @param {string} repoRoot
250
+ * @param {string[]} globs repo-relative picomatch patterns
251
+ * @param {{ fsImpl?: typeof fs }} [deps]
252
+ * @returns {{ file: string, line: number, text: string }[]}
253
+ */
254
+ export function findRawTmpdirMkdtemp(repoRoot, globs, { fsImpl = fs } = {}) {
255
+ const patterns = (globs ?? []).filter(Boolean);
256
+ // Negation is handled here rather than handed to picomatch: passing a
257
+ // mixed `['a/**', '!a/b']` array makes the `!` entry read as its own
258
+ // positive "everything but a/b" matcher, which silently *widens* the
259
+ // scan instead of narrowing it.
260
+ const include = patterns.filter((p) => !p.startsWith('!'));
261
+ const exclude = patterns
262
+ .filter((p) => p.startsWith('!'))
263
+ .map((p) => p.slice(1));
264
+ if (include.length === 0) return [];
265
+ const isIncluded = picomatch(include);
266
+ const isExcluded = exclude.length > 0 ? picomatch(exclude) : () => false;
267
+ const findings = [];
268
+ for (const rel of walkFiles(repoRoot, fsImpl)) {
269
+ if (!isIncluded(rel) || isExcluded(rel)) continue;
270
+ const lines = fsImpl
271
+ .readFileSync(path.join(repoRoot, rel), 'utf8')
272
+ .split('\n');
273
+ lines.forEach((text, i) => {
274
+ if (!RAW_TMPDIR_MKDTEMP.test(text)) return;
275
+ if (text.includes(LINT_ESCAPE)) return;
276
+ if (i > 0 && lines[i - 1].includes(LINT_ESCAPE)) return;
277
+ findings.push({ file: rel, line: i + 1, text: text.trim() });
278
+ });
279
+ }
280
+ return findings;
281
+ }
282
+
283
+ /**
284
+ * Walk `root` for JavaScript sources, returning POSIX-normalised relative
285
+ * paths. Unlike the `test-isolate` walker this descends dot-prefixed
286
+ * directories (the `.agents/` payload carries `__tests__` trees) while
287
+ * still skipping the trees that are never source: `node_modules`,
288
+ * `.worktrees`, and `.git`.
289
+ *
290
+ * @param {string} root
291
+ * @param {typeof fs} fsImpl
292
+ * @returns {string[]}
293
+ */
294
+ function walkFiles(root, fsImpl) {
295
+ const out = [];
296
+ const skip = new Set(['node_modules', '.worktrees', '.git', 'temp']);
297
+ const walk = (dir, prefix) => {
298
+ if (!fsImpl.existsSync(dir)) return;
299
+ for (const ent of fsImpl.readdirSync(dir, { withFileTypes: true })) {
300
+ if (skip.has(ent.name)) continue;
301
+ const rel = prefix ? `${prefix}/${ent.name}` : ent.name;
302
+ if (ent.isDirectory()) {
303
+ walk(path.join(dir, ent.name), rel);
304
+ } else if (/\.(?:js|mjs|cjs)$/.test(ent.name)) {
305
+ out.push(rel);
306
+ }
307
+ }
308
+ };
309
+ walk(root, '');
310
+ return out.sort();
311
+ }
@@ -204,7 +204,7 @@ async function logConfirmResult(result, terminal, config) {
204
204
  status: terminal?.status,
205
205
  },
206
206
  });
207
- emitTerminalEnvelope(terminal);
207
+ emitTerminalEnvelope(terminal, { config });
208
208
  await emitTerminalFriction({ envelope: terminal, config });
209
209
  return { success: terminal.status !== 'failed', result, terminal };
210
210
  }
@@ -49,7 +49,9 @@ multi-capability enumeration). Size is enforced where ground truth is available:
49
49
  the diff backstop in step 4. Do not talk yourself past that one.
50
50
 
51
51
  Sensitivity is the exception and stays absolute: a footprint touching an auth,
52
- crypto, billing, or migration class routes `full` however small or mechanical.
52
+ crypto, billing, or migration class routes `full` however small or mechanical
53
+ and unlike a ceiling, it is **not overridable** (§ Recording a proceed-light
54
+ answer).
53
55
 
54
56
  ## Four invariants (do not skip one)
55
57
 
@@ -58,9 +60,11 @@ crypto, billing, or migration class routes `full` however small or mechanical.
58
60
  **and** a ledgered model verdict with a recorded reason. Both must agree on
59
61
  `lite`.
60
62
  2. **Over-scope stops — it never hard-fails.** An over-ceiling prompt STOPS and
61
- asks the operator to escalate to `/plan` or proceed light. Under `--yes` it
62
- fails closed to an **`escalated` terminal envelope** that ends the session
63
- Escalation is terminal).
63
+ asks the operator to escalate to `/plan` or proceed light. **Both answers
64
+ are executable** `--operator-proceed-light` records the second one
65
+ Recording a proceed-light answer). Under `--yes` it fails closed to an
66
+ **`escalated` terminal envelope** that ends the session (§ Escalation is
67
+ terminal).
64
68
  3. **Diff-derived backstop.** After implementation the ACTUAL change set is
65
69
  re-checked — the diff is the real scope signal — and an over-ceiling diff is
66
70
  blocked rather than landed.
@@ -89,7 +93,10 @@ crypto, billing, or migration class routes `full` however small or mechanical.
89
93
  `nextCommands`. Continue to step 2.
90
94
  - **`ask-operator`** — predicted scope exceeds the light ceilings. STOP and
91
95
  ask the operator to escalate to `/plan` or proceed light. Do not proceed
92
- on your own. This is a **question, not a terminal** — wait for the answer.
96
+ on your own. This is a **question, not a terminal** — wait for the answer,
97
+ then act on it: *escalate* leaves for `/plan`, *proceed light* re-runs the
98
+ same command with `--operator-proceed-light "<their reason>"`
99
+ (§ Recording a proceed-light answer).
93
100
  - **over-scope under `--yes`** — no `action` to branch on: the gate emits an
94
101
  **`escalated` terminal envelope** instead (exit 2). § Escalation is
95
102
  terminal governs; you are finished.
@@ -144,6 +151,39 @@ crypto, billing, or migration class routes `full` however small or mechanical.
144
151
  [`deliver-digest.md`](deliver-digest.md) § 5 — every close
145
152
  gate runs byte-identical to the full path.
146
153
 
154
+ ## Recording a proceed-light answer {#recording-a-proceed-light-answer}
155
+
156
+ The gate offers the operator two options, so **both** have to be executable.
157
+ Re-run the identical gate command with their answer appended:
158
+
159
+ ```bash
160
+ node .agents/scripts/deliver-light.js --prompt "<prompt>" … \
161
+ --operator-proceed-light "<the operator's reason, in their words>"
162
+ ```
163
+
164
+ The gate then proceeds light, records the decision in the receipt Story, and
165
+ returns it on the envelope's `override`. Do **not** instead re-shape the
166
+ prediction — shrinking `--refactors` until the gate stops objecting is
167
+ under-declaring the footprint, which is the one thing the coarse design must
168
+ not reward.
169
+
170
+ It is deliberately narrow, and a refusal is printed rather than silent:
171
+
172
+ - **Only a size prediction is waivable** — change kinds, magnitude,
173
+ uncertainty, deployable span. A sensitive-path class, a
174
+ migration-with-consumers span, and an unknown footprint (undeclared, glob,
175
+ no acceptance, unclassifiable) are refused: those are risk, not size, and
176
+ § Scope by effort keeps them absolute.
177
+ - **The ledgered verdict still stands on its own.** The override substitutes
178
+ for the predicted *shape* only; `--route lite --reason "<why>"` is still
179
+ required.
180
+ - **Attended-only.** With `--yes` it is a usage error, not a quiet no-op —
181
+ an unattended run has no operator whose answer this could be, and over-scope
182
+ there still fails closed (§ Escalation is terminal).
183
+
184
+ What licenses this at all is step 4: the operator waives a *guess*, never the
185
+ diff backstop, which re-checks the actual change set against ground truth.
186
+
147
187
  ## Escalation is terminal {#escalation-is-terminal}
148
188
 
149
189
  Over-scope under `--yes` emits a schema-validated `story-deliver-terminal`
@@ -745,6 +745,39 @@ unconfirmed merge is a **contract violation** — the parent cannot distinguish
745
745
  "still working" from "done but silent". `pending` is the honest,
746
746
  machine-readable alternative: "not finished, here is exactly how to continue."
747
747
 
748
+ ### The envelope also lands on disk
749
+
750
+ Stdout has exactly one reader — the turn that launched the close — and that
751
+ reader is not always still listening. A child that reports progress and ends
752
+ its turn while its close is mid-gate-chain is behaving reasonably, but the
753
+ envelope it never relayed is gone, and reconstructing the Story's state from
754
+ labels costs a recovery round trip plus a full resume of the child. Observed
755
+ four times across three workers in a single consumer run, on unrelated
756
+ footprints, and not new to that run.
757
+
758
+ So `emitTerminalEnvelope` — the one writer behind every emit site — also
759
+ persists the validated envelope to
760
+ `<tempRoot>/orchestration/story-deliver-terminal-<storyId>.json`:
761
+
762
+ - **It is the same object**, not a summary. Read it and branch exactly as you
763
+ would on stdout; the copy is written before the markers are, so a caller
764
+ that saw them can rely on the file.
765
+ - **It is best-effort.** A failed write returns null and changes nothing about
766
+ the emitted envelope or the exit code — a landed PR must never become a
767
+ crash because a temp directory was unwritable.
768
+ - **It is a fallback, not a licence.** A worker still holds its turn until the
769
+ envelope arrives; see [`agents/story-worker.md`](../../agents/story-worker.md).
770
+
771
+ `deliver-recover.js` reads the same artifact, plus the freshness of
772
+ `close-gates-<storyId>.log`, to split the one genuinely ambiguous row of its
773
+ table. `agent::executing` with no PR used to answer "Implementation never
774
+ finished" — false for the whole duration of a close, whose gates and push
775
+ happen before any PR exists, and actively hazardous, because acting on its
776
+ re-init suggestion can put a second close on one PR. It now answers
777
+ `close-in-flight` (a gate log touched inside the window: wait, then re-probe)
778
+ or `close-envelope-on-disk` (the close already reached a verdict: relay it),
779
+ and falls back to the original verdict only when neither artifact exists.
780
+
748
781
  ### Exit-code compatibility note (`--no-wait-merge`)
749
782
 
750
783
  Every close flag keeps its meaning, but the **exit code** of a
@@ -148,10 +148,13 @@ budget is exhausted. Reference § Step 7.
148
148
 
149
149
  ## Recovering a stranded Story {#recover}
150
150
 
151
- Unclear state (killed run, lost envelope, a re-run refusal incl.
152
- merged-but-label-stale)? Do not guess — probe **read-only** with
151
+ **Lost envelope first: read it off disk.** Close persists each to
152
+ `temp/orchestration/story-deliver-terminal-<storyId>.json`; branch on it per
153
+ digest § 5. Otherwise (killed run, re-run refusal, merged-but-label-stale)
154
+ do not guess — probe **read-only** with
153
155
  `node .agents/scripts/deliver-recover.js --story <storyId>`; it prints the
154
- **one** next command with its evidence, never a menu.
156
+ **one** next command with its evidence, never a menu. A live close answers
157
+ `close-in-flight`: wait, never re-init underneath it.
155
158
 
156
159
  ## Idempotence & constraints
157
160
 
@@ -42,8 +42,8 @@ On a confirmed `deliverLightSuggestion`, `/plan` routes into
42
42
  [`deliver-light.md`](deliver-light.md) **without ending the session**. Two
43
43
  things make that safe, and both are worth understanding before changing it:
44
44
 
45
- 1. **The handoff carries the envelope, not the seed.** Gate #1 already holds a
46
- codebase snapshot and `complexitySignals`; fill the light gate's `--creates`
45
+ 1. **The handoff carries the envelope, not the seed.** Gate #1 already holds
46
+ the interrogated `complexitySignals`; fill the light gate's `--creates`
47
47
  / `--refactors` / `--acceptance` / `--reason` from those. Re-deriving from
48
48
  raw seed text throws away the better signal and can disagree with the
49
49
  suggestion that routed you.
@@ -135,8 +135,8 @@ ceilings on `STORY_SHAPE_CEILINGS` in
135
135
  ## Correct-by-construction authoring template
136
136
 
137
137
  `plan-context.js --out` writes `stories.template.json` as a
138
- **correct-by-construction** skeleton, built from the same repo snapshot the
139
- `complexitySignals` probed:
138
+ **correct-by-construction** skeleton, built from the same repo probe the
139
+ `complexitySignals` ran:
140
140
 
141
141
  - **`verify[]` placeholders already end with a valid `(tier)` tag.** Keep
142
142
  every filled entry's trailing tag one of `(unit)` / `(contract)` /
@@ -161,6 +161,21 @@ A faithfully-filled skeleton — placeholders replaced, pre-resolved entries
161
161
  kept, tags valid — passes the persist ticket validators with no
162
162
  round-trip.
163
163
 
164
+ ### Authored entry shape
165
+
166
+ Each `stories.json` entry: `slug` (`^[a-z0-9][a-z0-9-]*$`), `type: "story"`,
167
+ `title`, `body` (`goal`, optional `spec`, `changes[{path, assumption}]` —
168
+ `creates|refactors-existing|deletes`, `non_goals`, `reason_to_exist`),
169
+ top-level `acceptance[]`, `verify[]` (`… (unit|contract|e2e|validate)`), and
170
+ `depends_on[]` (N>1 only).
171
+
172
+ Nothing in that shape inventories the repo for the author. `changes[]` arrives
173
+ pre-resolved against the working tree, and Phase 8's
174
+ `validateStoryFileAssumptions` re-probes every `{path, assumption}` at persist
175
+ as a hard error — so the grounding contract is the author's own targeted reads
176
+ plus that gate. There is no pre-computed codebase snapshot to fall back on,
177
+ and no manifest-derived replacement to build.
178
+
164
179
  ## Tickets mode — authoring `supersedes[]`
165
180
 
166
181
  In `--tickets` mode each Story carries a top-level `supersedes` array claiming
@@ -64,7 +64,7 @@ node .agents/scripts/plan-context.js --seed "<seed>" \
64
64
  and derives source ids from its `sourceTickets[]`; the CLI also writes
65
65
  **`stories.template.json`**, the skeleton step 2 starts from.
66
66
 
67
- The envelope carries docs context, the codebase snapshot, the story-author
67
+ The envelope carries docs context, the story-author
68
68
  prompt, `sourceTickets[]`, `duplicates[]` (open **Stories**, never Epics), and
69
69
  advisory `complexitySignals` (**no routing authority**). A trivial scope earns
70
70
  `--route-downgrade-reason "<why>"` at persist — shape-validated, failing closed
@@ -93,11 +93,12 @@ valid. `body` is a markdown string **or** a structured object; persist parses
93
93
  either, serializes the canonical markdown, and syncs top-level `acceptance[]` /
94
94
  `verify[]` into it — never dual-author those lists.
95
95
 
96
- Each entry (the `stories.template.json` shape): `slug`
97
- (`^[a-z0-9][a-z0-9-]*$`), `type: "story"`, `title`, `body` (`goal`, optional
98
- `spec`, `changes[{path, assumption}]` `creates|refactors-existing|deletes`,
99
- `non_goals`, `reason_to_exist`), top-level `acceptance[]`, `verify[]` (`…
100
- (unit|contract|e2e|validate)`), `depends_on[]` (N>1 only).
96
+ **Grounding = your reads + Phase 8.** Nothing inventories the repo for you:
97
+ read each file you cite, then persist's file-assumption gate hard-errors on
98
+ every `{path, assumption}` that misses the real tree.
99
+
100
+ Entry fields (the `stories.template.json` shape):
101
+ [reference](helpers/plan-reference.md).
101
102
 
102
103
  Artifacts under `temp/plan-<slug>/`: `stories.json` (**length 1 by default**;
103
104
  over-budget Specs fail closed — split or tighten, never under `docs/`);
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [2.21.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.20.0...mandrel-v2.21.0) (2026-07-28)
6
+
7
+
8
+ ### Added
9
+
10
+ * deliver-light: make the gate's `proceed-light` answer representable, attended-only, and auditable ([#4815](https://github.com/dsj1984/mandrel/issues/4815)) ([#4817](https://github.com/dsj1984/mandrel/issues/4817)) ([74aa28e](https://github.com/dsj1984/mandrel/commit/74aa28e57d0192bcad698461d600806a77de092f))
11
+ * persist the close terminal envelope and teach recovery a live-close state (refs [#4816](https://github.com/dsj1984/mandrel/issues/4816)) ([#4819](https://github.com/dsj1984/mandrel/issues/4819)) ([ffa61d8](https://github.com/dsj1984/mandrel/commit/ffa61d8f56f39c33705bfa379cefdcaaeb523115))
12
+
13
+ ## [2.20.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.19.0...mandrel-v2.20.0) (2026-07-27)
14
+
15
+
16
+ ### Added
17
+
18
+ * retire the planner codebase snapshot; Phase 8 file-assumption validation is the grounding gate ([#4811](https://github.com/dsj1984/mandrel/issues/4811)) ([#4812](https://github.com/dsj1984/mandrel/issues/4812)) ([48621a8](https://github.com/dsj1984/mandrel/commit/48621a8fdcbfa5076a1c94ebe9098bb36736db09))
19
+ * route every suite temp dir under one reaped per-process root and guard the OS temp root against regression ([#4808](https://github.com/dsj1984/mandrel/issues/4808)) ([#4809](https://github.com/dsj1984/mandrel/issues/4809)) ([2e81c05](https://github.com/dsj1984/mandrel/commit/2e81c05f3113378ff2ed96b0f1802c406f4e61bf))
20
+
5
21
  ## [2.19.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.18.0...mandrel-v2.19.0) (2026-07-27)
6
22
 
7
23
 
@@ -56,6 +56,7 @@ import { retireMiDropKnobs } from './steps/2.1.0-retire-mi-drop-knobs.js';
56
56
  import { retireVerifyConcurrencyCap } from './steps/2.1.0-retire-verify-concurrency-cap.js';
57
57
  import { retireEpicAcTags } from './steps/2.2.0-retire-epic-ac-tags.js';
58
58
  import { retireMaxSeedWords } from './steps/2.11.0-retire-max-seed-words.js';
59
+ import { retireCodebaseSnapshot } from './steps/2.20.0-retire-codebase-snapshot.js';
59
60
 
60
61
  /**
61
62
  * Ordered registry of migration steps. MUST stay sorted ascending by
@@ -73,6 +74,7 @@ export const migrations = [
73
74
  retireVerifyConcurrencyCap,
74
75
  retireEpicAcTags,
75
76
  retireMaxSeedWords,
77
+ retireCodebaseSnapshot,
76
78
  ];
77
79
 
78
80
  /**