tldr-experts 0.3.1 → 0.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,895 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 — 2026-09-01
4
+
5
+ ### Changed
6
+
7
+ - **Dependent epics share ONE integration branch (#57, owner decision 2026-09-01, option (a)).**
8
+ One-branch-per-epic assumes the epics are independent. Run `260829-scoring-leaderboard` planned
9
+ E2 (the API) with E3 and E4 (mobile) consuming it, and a downstream story's base — cut from its
10
+ own epic branch, itself cut from `main` — could not see the upstream epic's merged work. It broke
11
+ twice, and both times the host fast-forwarded the EPIC branches by hand: cross-epic surgery done
12
+ with a feature built for stale STORY bases (design §F.2), which collapses branches the owner may
13
+ have meant to merge separately. A run whose epics form a dependency chain now cuts a single
14
+ integration branch and the epics become labels.
15
+ - **Detected at PLAN time, from what the plan already says.** A story whose `depends_on` names a
16
+ story in another epic IS the chain. `validatePlan` reports the cross-epic edges it read
17
+ (`PlanReport.epicChain`, deduplicated per epic pair), and the `plan` gate check states the branch
18
+ model in its passing detail — `epics form a chain (E3→E2, E4→E2) → single integration branch
19
+ \`epic/<run-id>\`` or `independent epics → one branch each`. The owner never discovers it
20
+ mid-Build, which is the half of #57 that was not about branches at all.
21
+ - **`epic/<run-id>`, not a new namespace.** `EPIC_BRANCH_RE`, `watch`'s feature-slug extraction,
22
+ `ship`, `boundary` and the `--reuse-epic` guard are all keyed on the `epic/` prefix; an
23
+ `integration/…` branch would have changed every one of them to buy a word. The run id IS in the
24
+ name — unlike an ordinary epic branch, which is deliberately unscoped because an epic is the unit
25
+ a team merges — because an integration branch belongs to one run by definition.
26
+ - **One run-scoped epic worktree**, `_epic-<run>-integration`, because git will not check one branch
27
+ out in two worktrees. It is picked up by the `_epic-<run>-` prefix that the §2.8 src resolver and
28
+ the run-close cleanup (#16) already enumerate, so the lifetime decision from that change holds.
29
+ - **Backward-safe by an ABSENT key.** The Build executor records what it used in `run.yml`
30
+ (`build.branch_model: per-epic | integration`, additive and optional). A run.yml that names
31
+ branches and no model predates the key — the three closed runs, and any run mid-flight — and stays
32
+ `per-epic`, so it resumes on the branches it already cut rather than being re-pointed at one that
33
+ was never cut. A model, once written, is never rewritten.
34
+ - **Nothing else moved.** The dirty-tree refusal, the foreign-epic refusal and `--reuse-epic`, the
35
+ gated HEAD, `git merge --no-ff` into the epic worktree, and the story-base fast-forward all behave
36
+ exactly as before; only WHICH branch they name changes. `tldrx ship` needed no change: a chained
37
+ run claims one branch, so it opens one PR with no `--branch`, and an unchained multi-epic run still
38
+ asks which.
39
+ - The acceptance test is the leaderboard shape, passing: S1 in E1 writes a file, S2 in E2 depends on
40
+ it, and S2's worktree holds that file with **no `story.base_fastforwarded` event** — the base was
41
+ right when the developer was dispatched, not repaired afterwards. Red before the change (`Expected:
42
+ true, Received: false`).
43
+
44
+ - **An epic worktree now lives for the RUN's lifetime, not the Build stage's (#16, owner decision
45
+ 2026-09-01, option (a)).** The shipped half of #16 made a `file` src resolve against
46
+ `.tldrx/worktrees/<repo>/_epic-<run>-<epic>` before the working tree — and then `BuildSession.finish()`
47
+ removed that directory before the Build handoff was even written, so unless the operator had typed
48
+ `--keep-worktrees` a later Watch stage had nothing to resolve against and the fix bit only under a flag.
49
+ Cleanup moves to run CLOSE, which is what the checkout actually belongs to.
50
+ - **Watch citing epic-only code works by default.** Proved end to end rather than by construction:
51
+ `test/build-executor.test.ts` runs the real pipeline, then resolves a `[src: app:s1.txt:1]` against a
52
+ file the test first shows is committed on `epic/e1` and absent from the working tree.
53
+ - **Every close path takes them**, because a run does not only close one way: `tldrx next` closing the
54
+ last stage, `tldrx approve` signing the last gate, and `tldrx run cancel`. Without the last two the
55
+ change would have traded one leak for another — a cancelled run's checkouts used to be gone already,
56
+ because Build removed them on the way past.
57
+ - **`--keep-worktrees` keeps its meaning, one scope wider**: survive even the run close. It is
58
+ remembered on the run as `keep_worktrees:` (additive and optional; absent means clean up, which is
59
+ what every existing run.yml meant) because the flag is typed on the `tldrx next` that BUILDS and the
60
+ run is usually closed by a different command in a different process.
61
+ - Story worktrees are untouched: still removed the moment a story reaches `done` or `blocked`.
62
+
63
+ - **The budget gate's three open policy questions are answered (#22, owner decision 2026-09-01).**
64
+ bb6204b wired the DATA — both economies, `attended_by`, `runSpend` — and deliberately changed no
65
+ verdict. These are the verdicts.
66
+ - **(a) An `attended_by: host` run is INFORMED, never DENIED, on metered dollars.** `tldrx next` on
67
+ such a run spawns nothing, so the estimate the gate was refusing against is spend that provably will
68
+ not happen. Both the PreToolUse hook and `tldrx next`'s own brake now say every number they would
69
+ have refused with, plus both economies, and allow. The event is `budget.warned`, not `budget.blocked`:
70
+ nothing was blocked, and recording a block that did not happen is the exact failure #22 was filed
71
+ about.
72
+ - **(b) A `host-tokens` ceiling is soft-enforced.** Under that economy the ceiling NUMBER is a
73
+ host-session token allowance, so accumulated declared `tokens:` against it is the one comparison in
74
+ the gate whose two sides share a unit. Crossing it WARNS and still allows. It stops only under the
75
+ explicit opt-in **`on_host_tokens_exceed: block`** in `budget.yml` — an enum beside `on_exceed`,
76
+ defaulting to `warn`, so every file written before the key existed keeps the behaviour it had. The
77
+ refusal never offers `tldrx budget raise`, which moves dollars and would send the operator at the
78
+ wrong number. (a) beats (b): the opt-in still never denies an attended run.
79
+ - **(c) `remainingWork` zeroes the developer share on an attended run**, mirroring `economy:
80
+ host-tokens`, because it is the same fact — the host session pays for those turns. Attendedness and
81
+ the phase economy were independent, so an attended run on a `metered-usd` phase still counted
82
+ developer turns against money this framework will never spend, on the brake and in `run estimate`
83
+ alike. Reviewer floors are untouched in both cases.
84
+
85
+ ### Added
86
+
87
+ - **The documentation site speaks Spanish (`docs-site/es/`, phase 2).** All twelve English pages are
88
+ now mirrored under `/es/` at the same paths and translated into es-MX developer Spanish, and
89
+ `locales.es.themeConfig` carries the Spanish sidebar, nav, edit link and page chrome (outline,
90
+ prev/next, search modal, footer) rather than English chrome around Spanish prose. The placeholder
91
+ that phase 1 left at `/es/` is replaced by the translated landing page.
92
+ - **tldrx's own vocabulary stays in English where it is an identifier.** The stage names
93
+ (`What → How → Plan → Build → Watch`), `run`, `story`, `DoD`, `scope`, `handoff` and `workspace`
94
+ are the things you type or the files on disk, so they are not translated; each is glossed once in
95
+ Spanish where it first appears. `gate` is the one exception — it renders as **compuerta**, because
96
+ the phase-1 placeholder had already shipped that word to the live site.
97
+ - **Code blocks and command output are verbatim English** — a translated transcript would be a
98
+ transcript of a command nobody ran. The narration around them is translated.
99
+ - **Anchors were verified against the rendered HTML, not assumed**, because dead-link checking does
100
+ not see them. That mattered here: VitePress's slugifier strips accents but *keeps* `¿`, so
101
+ `## ¿Cómo lo detengo?` becomes `id="¿como-lo-detengo"`. The one heading that is linked to carries
102
+ an explicit `{#puedo-manejarlo-desde-claude-code}`. A sweep of the built site resolves all 5
103
+ anchor links across its 26 pages, 0 broken.
104
+ - **Release notes are deliberately not translated**: the page is generated from `CHANGELOG.md` at
105
+ build time, so a Spanish copy would drift. The `/es/` sidebar links the English page and says so.
106
+
107
+ - **A public documentation site, written for people who have never seen tldrx (`docs-site/`, phase 1).**
108
+ A VitePress site deployed to GitHub Pages by `.github/workflows/docs.yml` on any push to `main` that
109
+ touches `docs-site/` or `CHANGELOG.md`. Twelve short English pages — a landing page, a Quickstart, one
110
+ page per concept (the five stages, files-as-state, gates, evidence, budgets), four guides and a
111
+ condensed CLI overview — plus a generated changelog and a Spanish placeholder. None of them is pasted
112
+ from `docs/`, which stays the agent-facing reference. Every command
113
+ and every block of output on the Quickstart was produced by running the real binary; nothing on the
114
+ site documents a flag that `--help` does not.
115
+ - **The changelog page is generated, never copied.** `docs-site/scripts/gen-changelog.ts` reads this
116
+ file at build time and emits one line per entry, so a release note reaches the website without
117
+ anybody maintaining a second copy of it. The generated page is gitignored for the same reason.
118
+ - VitePress dead-link checking is left ON and the build is green with it — proven by a probe, not
119
+ assumed: a deliberate link to a missing page failed the build with `1 dead link(s) found`. (Anchors
120
+ are NOT checked by it, so `#fragment` targets were verified against the rendered HTML by hand.)
121
+ - i18n is wired now, with the English content at the root and a Spanish placeholder under `/es/`, so
122
+ phase 2 is a matter of adding files rather than restructuring the site.
123
+ - `docs-site/` is excluded from the npm package (it is not in `files:`) and from `tsc --noEmit` (the
124
+ root tsconfig includes only `bin`, `src`, `test`) — measured: `npm pack --dry-run` still lists 52
125
+ files and none of them is under `docs-site/`.
126
+
127
+ - **`tldrx learn` chapters 3-8 — the whole loop, played (#30, phase 2).** The tutorial now runs end to
128
+ end in about five seconds of real commands: **3** the gate (`approve --note`, and the record it writes
129
+ in `run.yml`), **4** one story built for real (How's `auto` gate closing itself over its seven
130
+ conditions, Plan's human gate, then a Build that cuts `epic/bulk-pricing`, spawns a developer in a
131
+ worktree, re-runs the story's `npm run test` DoD, commits, merges and spawns a reviewer), **5** a
132
+ genuinely red DoD and the three commands back from it (`story reopen`, `reject`, `budget raise`),
133
+ **6** an `agent` gate closed by `approve --as-agent` over a structured evidence note, **7**
134
+ `run attend host`, the refusal a bare `next` then gives, and the `next --prepare` / `next --commit`
135
+ pair actually run, **8** `cost --all`, `run estimate`, and the budget brake refusing a stage the
136
+ phase can no longer afford.
137
+ - Chapter 4's DoD is real, chapter 5's failure is real: the story's test script is `exit 0` until a
138
+ developer replaces it with a `node` test that then catches a wrong number — so the tutorial teaches
139
+ "a green DoD over an empty test proves nothing" by letting it happen rather than by saying it.
140
+ - Chapter 5 opens a second run, so `{run}` and `{runDir}` now expand in a step's `command` as well as
141
+ in a turn's writes, and mean **the newest run that is still open** — the same set `resolveRun` picks
142
+ from, so the placeholder and the CLI cannot disagree about which run a command means.
143
+ - Chapter 4's `prepare()` commits what `init` left untracked, because the Build executor refuses to
144
+ cut a branch from a dirty tree (measured: `?? .gitignore, ?? CLAUDE.md`, exit 2) — and the narration
145
+ teaches that refusal instead of hiding it.
146
+ - **Fixed: the toy repo now carries its own git identity.** Chapter 4's Build commits through the
147
+ framework's own executor, with whatever identity the machine has — so on a box with no global
148
+ `user.email` (a fresh laptop, a container, `ubuntu-latest`) `git commit` failed with `Author identity
149
+ unknown` and the chapter died three commands in. `makeSandbox` writes `user.email`, `user.name` and
150
+ `commit.gpgsign=false` into the sandbox repo's own config, on every open, so an older sandbox is
151
+ repaired too. The test pins it with `user.useConfigOnly` rather than an empty global config: git
152
+ guesses an identity from gecos and hostname and only fails where it cannot, so an empty config
153
+ passes on a laptop and fails in a container — which is exactly how this reached CI.
154
+
155
+ - **`tldrx learn` — a playable sandbox tutorial that runs the REAL commands (#30, phase 1 of 3).**
156
+ A tutorial that runs the shipped binary can never drift from the shipped behaviour: every output the
157
+ learner reads is produced by the code, not written down by a doc author. `tldrx learn` scaffolds a
158
+ throwaway workspace (a four-file git repo with a `test` script that exits 0), then narrates, shows the
159
+ exact command, waits for Enter and RUNS it — `tldrx init`, `run new`, `next`, `answer` are the real
160
+ ones, against that sandbox.
161
+ - **Chapters 1 and 2 ship playable**: init (read the `workspace.yml` detection actually produced) and
162
+ the What stage (a question comes back, `tldrx answer` records it, it becomes `F001` in
163
+ `.tldrx/memory/facts.yml`). Chapters 3-8 from the issue are phase 2 and are DATA plus one
164
+ `assert()` — see the contract in `src/core/learn/Chapter.ts`.
165
+ - **It cannot spend money, by construction rather than by convention.** The sandbox writes its own
166
+ `claude` stand-in, names it in `TLDRX_CLAUDE_BIN` and puts it first on the child `PATH`, so neither
167
+ the spawn seam nor a bare `claude` on `PATH` can reach the real CLI. `test/learn.test.ts` proves it
168
+ the only way worth proving: it plants a booby-trapped `claude` that writes a marker file, plays both
169
+ chapters, and asserts the marker is absent AND the chapters completed — because a tutorial that
170
+ spawned nothing would pass a marker check for the wrong reason.
171
+ - **It cannot touch your work.** Everything is written under the sandbox directory (`~/.tldrx-learn`
172
+ by default, `--sandbox` to move it), and a sandbox that would sit inside a real tldrx workspace is
173
+ refused before a byte is written.
174
+ - **Files as state, like everything else**: `progress.json` in the sandbox is what makes a bare
175
+ `tldrx learn` resume, `--chapter <n>` jump (playing an unfinished prerequisite first), and `--reset`
176
+ start over. With no terminal on stdin the chapters play straight through rather than hanging at the
177
+ first prompt.
178
+ - The stand-in agent is scripted per chapter and is **fail-closed**: a prompt no turn matches exits 1
179
+ and names the turns it did have, so a hole in the tutorial is a loud failure rather than an
180
+ improvised answer. The `stream-json` writer moved from `test/fixtures/fakeStream.ts` to
181
+ `src/core/facilitator/fakeTranscript.ts` — beside `agentEvents.ts`, which reads that format — so the
182
+ tutorial's stand-in and the four test fakes cannot drift apart. The fixture re-exports it.
183
+
184
+ - **`tldrx note <run> [--stage <id>] "text"` — an honest carrier for an operator annotation, at the
185
+ moment it happened (#46).** Measured on `260829-scoring-leaderboard` (2026-09-01): a host performed an
186
+ owner-delegated mechanical resync of eight story dod blocks, was asked to note it in the run log, and
187
+ could not — `events.jsonl` is append-only and tool-owned, so the only carriers were a FUTURE gate note
188
+ (late, and keyed to a decision the note is not about) or a `reject` (destructive). The session ended up
189
+ hanging the context off an unrelated `story.reopened`.
190
+ - **One event, and nothing else.** It appends a single `operator_note` line carrying actor, timestamp,
191
+ optional stage and the text. It does not go through `RunStore.save()`, which would rewrite `updated_at`
192
+ and re-derive every status for an annotation that changed no state: `test/operator-note.test.ts`
193
+ compares `run.yml` and `budget.yml` **byte for byte** across the call, because "safe to reach for
194
+ mid-run" is the whole of what makes the verb usable.
195
+ - **Every refusal writes nothing.** An unknown run (exit 3), a stage this run does not have (exit 2), an
196
+ empty note (exit 1) and a lone argument that turns out to name a run all refuse before the log is
197
+ opened. The last one is the trap worth naming: `tldrx note 260829-x` is a half-typed command, not a
198
+ note whose entire content is a run id, and recording it would be the one outcome nobody wanted.
199
+ - **Visible afterwards.** `tldrx run status` prints the last three (with `tldrx replay` named when there
200
+ are more), `--json` carries them as `operator_notes`, and `tldrx replay` narrates every one in place.
201
+
202
+ - **`tldrx run gates set <stage>:<policy> --note "…"` — the signed upgrade path for a frozen
203
+ `gates_policy` (#14).** The policy is resolved at `run new` and frozen into `run.yml` by design, and that
204
+ default is not taken back here. What it left with no door at all, found on the 2026-08-30/31 unattended
205
+ pilots: a run created BEFORE the `agent` policy existed can never use `approve --as-agent`, and `run.yml`
206
+ is hand-edit-forbidden (spec §1) — so the only move was to abandon the run.
207
+ - **Built like `story reopen`, because it is the same kind of act:** a person overruling state the machine
208
+ is holding. A `--note` is required; ONE stage per invocation (a list would let a second change ride on
209
+ the first one's signature); the entry must be QUALIFIED, because under `--gates` a bare `plan` means
210
+ `human` and a signature must not rest on a default; and a no-op is refused rather than recorded.
211
+ - **One `gate.policy_changed` event** carries the phase, who signed it, the old and the new value, and the
212
+ note. A run with no `gates_policy:` at all gets the FULL map written — every stage explicitly, with the
213
+ one change applied — because a partial map would quietly claim its other stages had been decided too.
214
+ - It changes who may CLOSE a gate from then on. Gates already signed are untouched, and nothing re-reads
215
+ the policy of a closed one.
216
+
217
+ - **`tldrx ship` — open a PR from the run's epic branch, with the handoff as the body (#15).** The loop
218
+ ended at "merge by hand": a finished epic sat on `epic/<slug>`, the document explaining it sat in
219
+ `<run>/<phase>/handoff.md`, and nothing carried either one to a PR.
220
+ - **It never pushes.** `core/build/git.ts` has no `git push` wrapper on purpose (spec §5), and this verb
221
+ keeps that rule rather than being the exception to it: a branch the remote has not seen is a refusal
222
+ that names the exact `git -C … push -u` command. Publishing a branch is a decision, and it stays the
223
+ operator's.
224
+ - **It never writes to the run** — no event, no gate, no cursor, no money — and it does not mirror
225
+ tickets: `tldrx tickets sync` already is that verb, holds the `process.yml` contract and appends
226
+ `ticket.synced`. A second, thinner mirror inside `ship` would give the workspace two answers to "is this
227
+ story mirrored", so `ship` names it as the next step instead.
228
+ - **Clean refusals, in a sentence:** no epic branch, no handoff, no `gh`, no remote, an unpushed branch,
229
+ several epic branches with no `--branch`, a branch the run did not cut. The body goes to `gh` as
230
+ `--body-file`, never as an argument, so a long handoff cannot overflow an argv limit.
231
+ - Both external binaries go through one narrow transport that takes a cwd — the same idea as
232
+ `adapters/transport.ts`, and the only way to ASSERT the argument shape of a command the suite must not
233
+ run. The unit tests drive a recording fake; the one end-to-end test puts a STUB `gh` first on PATH in a
234
+ throwaway workspace with a throwaway bare `origin`. The real `gh` is never invoked by a test.
235
+
236
+ - **`TLDRX_CLAUDE_BIN` — point the sub-agent spawn at a different binary (#27, minimal slice).**
237
+ `spawnAgent` hardcoded `claude`, so a pinned install, a wrapper that adds a proxy or credentials,
238
+ and a stand-in in a sandbox all required patching source. The variable replaces the executable
239
+ NAME and nothing else — the argv is still Claude Code's, so what it points at has to speak
240
+ `-p --output-format stream-json --json-schema` — and blank or whitespace counts as unset. Honoured
241
+ everywhere the CLI is spawned: `spawnAgent`, the `--dry-run` command line (`describeSpawn`) and
242
+ `claude mcp list` (`McpProbe`). `tldrx doctor` deliberately still checks `claude --version`,
243
+ because `env.yml` declares that string. Documented under **Environment variables** in the CLI
244
+ reference. This is not the provider abstraction #27 asks for; #27 stays open for it.
245
+
246
+ - **A drift guard on `templates/story.md` and `templates/epic.md` (#48).**
247
+ Both ship in the npm package, both state the Plan front-matter schema, and **nothing in `src/`
248
+ reads either one** — a second copy of a contract whose first copy is computed from `STORY_KEYS`
249
+ and `EPIC_KEYS`. Add a required key and `schemaContract.ts` stops compiling while the templates
250
+ say nothing; a human then opens one, writes a story the check refuses, and the framework looks
251
+ broken. They now go through `validateStoryFile` / `validateEpicFile` — the very checks the stage
252
+ gates on — with their key sets asserted equal to `STORY_KEYS` / `EPIC_KEYS` in order, and the
253
+ status enum each spells out in a comment asserted equal to `PLAN_STATUSES`. Proven to have teeth:
254
+ renaming one key and staling one enum comment turns three tests red. Whether the files should be
255
+ generated or deleted is a packaging decision and is left open on #48.
256
+
257
+ - **The merge-wave sandbox is built under a hostile `init.defaultBranch` (#49).**
258
+ `test/merge-wave.test.ts` names every repo `main`, and CI run 33459567355 failed in the test's
259
+ own setup — `git push -q origin main` → `src refspec main does not match any` — on a runner whose
260
+ default branch is not `main`. `f1ffe56` had already fixed it (`-b main` on both inits,
261
+ `--branch main` on the clone), but nothing EXERCISED the fix: on a `main`-defaulting host,
262
+ removing the treatment changes nothing. The sandbox now pins `init.defaultBranch: trunk` for
263
+ every git command it builds itself with, the clone asserts it is on `main` rather than
264
+ discovering it five lines later, and two tests pin the mechanism — untreated reproduces the CI
265
+ error verbatim, treated does not.
266
+
267
+ - **The Plan prompt now STATES the schema the `plan` check enforces, generated from the check itself (#35, #38).**
268
+ `stages/plan/stage.md` named the output filenames — `stories/<id>.md`, `epics/<epic>.md`, `waves.yml` — and
269
+ said nothing about their shape, so a fresh agent learned it by having a paid attempt refused. Measured twice
270
+ in two days: on `260831-hardening-d1` the plan sub-agent followed the rendered bundle faithfully and wrote
271
+ seven stories as plain markdown (`no YAML front matter — the file must open with ---`), and on
272
+ `260829-scoring-leaderboard` it wrote a 1,009-character acceptance item against a `MAX_ITEM_CHARS = 512`
273
+ cap that appeared in no file it could read. Both attempts were consumed, correctly and uselessly.
274
+ - **Generated, not copied.** `src/core/plan/schemaContract.ts` renders a `## Output schemas` section from
275
+ `STORY_KEYS`, `EPIC_KEYS`, `PLAN_STATUSES` and the six `MAX_*` constants — the same definitions
276
+ `validateStory`, `validateEpic` and `validateWaves` read. `Record<StoryKey, Field>` is load-bearing:
277
+ add a key to a schema and the file stops compiling until the new key has a value and a rule. The worked
278
+ examples it ships are run through `validatePlan` itself in the tests, so the contract the prompt states
279
+ and the contract the check enforces are provably the same one. This repo already had the other kind:
280
+ `templates/story.md` carries the schema correctly and `grep -rn 'story\.md' src/` finds nothing that
281
+ reads it.
282
+ - **Only where the check runs.** `applyCheckContracts` splices it under an H2 the framework owns, for a
283
+ stage that declares `checks: [plan]` AND writes `waves.yml` — the same predicate `checkPlan` skips on,
284
+ now shared (`writesPlanArtefacts`). A What or How prompt is byte-identical to before. It goes into
285
+ `stage.md` rather than after the inputs because `prompt.ts` orders the document most-stable-first for
286
+ the prompt cache, and a section computed from constants is exactly as stable as the stage body.
287
+ - **~4.2 KB against a $4 stage.** The alternative it replaces is a refused attempt per fresh workspace.
288
+
289
+ - **`tldrx plan sync-dod` — the mechanical repair for dod blocks an edited `workspace.yml` orphaned (#42).**
290
+ A story's ```dod block may only name commands `workspace.yml` declares, byte for byte, and that rule is not
291
+ relaxed by a byte here — it is what stops a data file from running an arbitrary command as you. What it
292
+ lacked was an inverse. Measured live 2026-08-31 on `260829-scoring-leaderboard`: fixing `workspace.yml`
293
+ (a filtered `test:`, `lint:` deleted) instantly invalidated the dod blocks of **8 approved stories**, and
294
+ the only recoveries were hand-editing agent-approved artefacts or re-running the whole Plan stage — a paid
295
+ turn to change two lines in eight files, churning thirteen correct stories on the way.
296
+ - **Evidence, not similarity.** The ancestry comes from git's history of `.tldrx/workspace.yml`: a line a
297
+ previous version declared under a role the current file still has becomes that role's current command; a
298
+ line whose role is gone is dropped; a line the current file already declares is left alone; and a line no
299
+ version ever declared — or one two roles once shared and now disagree on — is **flagged, its story left
300
+ byte-identical**, and the command exits `2`. Guessing at a rename by string shape is the one thing this
301
+ must not do. In a workspace with no git history there are no ancestors, so everything non-current is
302
+ flagged.
303
+ - **It touches nothing else.** Front matter, prose, blank lines and the fences come back byte-identical;
304
+ only the command lines inside the fence move. The previous version is kept at `<story>.md.bak`
305
+ (`writeAtomic`), the summary is a per-story diff, `--dry-run` prints it and writes nothing, and the result
306
+ is validated by the same plan check the drift came from. Stories that CAN be synced still are — one
307
+ undecidable line is not a veto on the other seven files.
308
+ - **The drift message now names the remedy.** "`<cmd>` is not one of .tldrx/workspace.yml's commands — a
309
+ story may not invent one" gained a second sentence pointing at `tldrx plan sync-dod`. Only for a STORY:
310
+ a stage's `cmd:` is a line a human wrote, and `sync-dod` does not touch stage files.
311
+
312
+ - **`tldrx answer <Qn> "…" --supersede` — the verb for reversing a decision already on record.**
313
+ Found live 2026-08-31: an owner reversed an answered decision after the risk behind it was
314
+ refuted, and `tldrx answer` refused ("Q1 is not an open question") because an answer is
315
+ recorded once. `superseded_by` had been in the §2.5 schema since the first draft with **no
316
+ command that wrote it**, so the only route was a hand edit of `facts.yml` — and a hand edit
317
+ that left `superseded_by: null` left the reversed decision inside `FactsStore.active`, which
318
+ every stage reads as never-re-ask truth. The next run would have reinstated the call the owner
319
+ had just taken back.
320
+ - **The verb.** Valid only on an ANSWERED question (on an open one it exits `1` and says to
321
+ answer it normally; without the flag an answered one still exits `3`, now naming
322
+ `--supersede`). It appends a new fact carrying the whole new answer with the same `area` and
323
+ `repos` and ordinary provenance, sets the old fact's `superseded_by` and the new one's
324
+ `supersedes` — both halves, through `FactsStore.supersede`, under the workspace lock, so the
325
+ reciprocity rule cannot be broken — and never edits a byte of the old fact's text. Reversing
326
+ twice supersedes the SECOND fact: the chain is walked to its head, so it stays single-link.
327
+ - **The questions block is appended to, not rewritten.** The original `[Answer]:` line and its
328
+ footer stand; a superseding answer line and a `reanswered_by | reanswered_at | fact |
329
+ supersedes` footer go under them. `status:` stays `answered`, because it is.
330
+ - **Every reader that feeds a decision now skips a superseded fact.** This was half the work
331
+ and none of it was new code: `superseded_by` had a writer for the first time, and six readers
332
+ had been filtering on retirement alone. `isLive` (`core/facts/Fact.ts`) is now the one
333
+ predicate behind `FactsStore.active` (no-re-ask, `tldrx run new --from` de-duplication),
334
+ `findDuplicate`, `renderFacts` (the `{{facts}}` section of **every** prepared prompt),
335
+ `renderWatchFacts`, `runFacts` (the implicit plan's "this run's answers") and `relevantFacts`
336
+ (the training miner). One test had pinned the old behaviour in words — "a
337
+ superseded-but-not-retired row stays visible" — and that was the bug, not the rule.
338
+ - **History readers still show it, labelled.** `tldrx replay` renders the new `fact.superseded`
339
+ event as its own line — the one moment the workspace's durable memory changes its mind was
340
+ the one moment replay could not narrate — and `tldrx retro` lists the old fact with
341
+ `(superseded by F<n>)` beside it.
342
+
343
+ ### Fixed
344
+
345
+ - **`tldrx learn` — the cold-player QA round (#30).** A first-time player played all eight chapters
346
+ and returned SHIP-with-fixlist. Everything they found is fixed or recorded:
347
+ - **Chapter 8 no longer lies about the brake.** It said "the phase has already spent its Watch
348
+ money" while the tool printed `$1.89 left … estimate is $2.00`. The real mechanism is that a
349
+ re-run is priced at the stage's WHOLE declared `budget_usd`, never at what a second attempt might
350
+ add — so a stage that has spent anything can no longer afford itself. The chapter now says that,
351
+ quotes both figures, and `assert()`s them against the `budget.blocked` event so the numbers cannot
352
+ drift away from the sentence.
353
+ - **Chapter 1 no longer promises something chapter 2 does not deliver.** `--no-interview` skips
354
+ *init's* setup interview, which no chapter covers; the forward reference is gone and the debrief
355
+ now sends the learner to `.tldrx/init-handoff.md`, where measured/inferred/assumed and
356
+ `[src: …]` / `absent:` actually live.
357
+ - **`tldrx learn --chapter <n>` refuses a chapter that is already played**, up front and by name,
358
+ instead of narrating it and then dying mid-chapter on `run new: … already exists` (exit 1,
359
+ measured). The refusal names `--reset` and the chapter a bare `learn` would resume at.
360
+ - **The tutorial has a door out.** The ending now names the first four commands to type on a real
361
+ repo — `tldrx init` (with the warning that it runs an interview by default), `run new --scope
362
+ hotfix`, `next`, and `tldrx ship`.
363
+ - **Chapter 7 RUNS `next --prepare` and `next --commit`** against the feature run's Watch stage
364
+ instead of describing them in a debrief. Chapters 6 and 7 swapped for it: the attended chapter
365
+ addresses the feature run through `{run}`, so the hotfix run has to be signed off first.
366
+ - **Every non-zero exit code is printed** (`→ exit 4`), so the code chapter 2 teaches is a thing
367
+ the learner reads rather than a thing they are told. Chapter 5 now also demonstrates the exit-2
368
+ refusal a bare `next` gives with two runs open, and names the two run-id spellings.
369
+ - Jargon defined at first use — expert, level 0, the `claim-sources` / `no-reask` / `budget-gate`
370
+ bracket, `boundary`, `[src: …]`, `absent:`, economy, §2.11 — and the `expert … has no evidence`
371
+ nudge explained once instead of repeating unexplained nine times.
372
+ - Known and NOT fixed: a step's stderr (where the agent stream lives) is buffered and printed after
373
+ its stdout, so a summary can appear before the stream that produced it. Interleaving needs an
374
+ `onStderrLine` on the runtime seam and in both implementations; documented in `engine.ts`.
375
+
376
+ - **`tldrx cost` no longer claims "two economies" over one (#56).** The `(no total: two economies, no
377
+ exchange rate)` footnote was unconditional, so a run whose every attempt was metered in dollars was
378
+ told no total could be printed. It is printed only when both economies are actually present.
379
+
380
+ - **The README's "Not on npm yet" warning was false and told readers not to run the install line
381
+ directly underneath it.** The package IS published: `npm view tldr-experts version` → `0.3.1`,
382
+ exit 0. The warning is removed rather than re-dated — the npm badge at the top of the README
383
+ already shows the live version, so nothing in its place can go stale the same way. The
384
+ `npm i -g tldr-experts` line it was contradicting is unchanged.
385
+
386
+ - **The site's own home page linked an anchor that does not exist.** The hero's "Try it offline,
387
+ free" button pointed at `/quickstart#try-the-whole-thing-first-for-free`, but the heading renders
388
+ as `id="first-try-it-for-free"` — verified against the LIVE page, not just a local build. Dead-link
389
+ checking never saw it because VitePress does not check fragments. Repointed, and a sweep of the
390
+ built site now resolves every anchor link it emits.
391
+
392
+ - **`tickets sync`, `tickets status` and `budget show` took a run id as a positional that neither
393
+ their `usage` nor their `--help` declared (#53).** Measured at `7ac298c`:
394
+ `tldrx tickets status zzz-positional-probe` and `tldrx budget show zzz-positional-probe` both reach the
395
+ run resolver and exit `3` with `no run 'zzz-positional-probe'`, so both forms have always been
396
+ supported. The mechanism is the same in each: the subcommand word is consumed by the dispatcher
397
+ (`tickets.ts:53`, `budget.ts:32`) before `stringFlag(args, "run") ?? args.positionals[0]` runs
398
+ (`tickets.ts:246`, `budget.ts:48`), so `positionals[0]` is a run id by then.
399
+ - **The capability is DECLARED, not removed.** `usage` now reads `tldrx tickets sync [<run>] …`,
400
+ `tldrx tickets status [<run>] …` and `tldrx budget show [<run>] …`, and both help entries gain the
401
+ `[<run>]` arg every other run-scoped command already carries. Nothing about what the CLI accepts
402
+ changed.
403
+ - **This is the axis #51's guard cannot see.** That guard compares the registry to the usage; here the
404
+ registry itself was narrower than the code, and where both are silent both are green. The new check
405
+ in `test/cli.test.ts` is a hand-written list — nothing derives a positional from source — but its
406
+ BEHAVIOURAL half spawns the real CLI against a throwaway workspace, so it also goes red if the
407
+ capability is ever removed, which is the direction a tidy-up of the arg parsing would break it in.
408
+
409
+ - **`tldrx run gates set` was documented nowhere in `docs/` (#54).** `grep -rn "gates set" docs/` returned
410
+ nothing at `7ac298c`, so the CLI reference — the page a reader lands on from the README — described
411
+ seven of `tldrx run`'s eight subcommands. It matters more than an ordinary docs gap because `gates set`
412
+ is the ONLY sanctioned way to move a `gates_policy` that `run new` froze, and the situation it exists for
413
+ (a run opened before the `agent` policy existed, which can otherwise never use `approve --as-agent`) is
414
+ one an operator hits mid-run and searches the docs for. What they found was "abandon the run".
415
+ - Documented in all three places the question gets asked from: the `tldrx run` usage block and a new
416
+ prose entry in `docs/guide/08-cli-reference.md`; a **Moving a frozen policy** section in
417
+ `docs/guide/03-runs-and-gates.md`, right under the paragraph that explains the freeze, with the
418
+ `--gate-agent` disclaimer further down now linking to it; and `docs/spec.md`, both in §2.2's
419
+ `gates_policy` row and as its own §CLI row (exits `0,1,2,3`, each measured).
420
+ - Every copy carries the two facts a usage line cannot: **`--note` is mandatory**, and the change
421
+ appends one **`gate.policy_changed`** event with the actor, the moment, the note and the old→new
422
+ value — the whole audit trail for a mutation nobody would otherwise go looking for.
423
+ - Guarded: `test/cli.test.ts` now asserts the CLI reference names every subcommand in
424
+ `runCommand.subcommands`, plus `gate.policy_changed` by name. Scoped to `run` on purpose —
425
+ `plan sync-dod` has no section on that page at all (a separate gap, unfiled), and `hook`'s seven
426
+ scripts are deliberately documented as one `<script>` slot.
427
+
428
+ - **The epic file duplicated every story's status, and nothing ever updated the copy (#50).**
429
+ Measured on `260829-scoring-leaderboard` (2026-09-01): `03-plan/epics/E1.md` listed S1, S2 and S3 as
430
+ `todo` in its `## Stories` table while `03-plan/stories/S1.md` said `done` (merged at `0a50660`,
431
+ `task.done` in `events.jsonl` at 23:46:11Z) and S2 said `in_progress`. Nothing had lied — nothing had
432
+ written, either. Fixed by **removing the copy**, not by adding a second writer, and the repo already
433
+ drew that line for the one field it does maintain: the epic's front-matter `status:` is DERIVED and
434
+ written by `BuildExecutor.updateEpicStatus` (`build.ts:2552`) from the story files. A copy with a
435
+ writer is a cache; a copy without one is a lie waiting to be read.
436
+ - **Nothing parsed the table.** `validateEpicFile` is front matter only ("the front matter is the whole
437
+ schema", `schemas/epic.ts:69`), `adapters/body.ts:50` mirrors an epic to a ticket as a bare list of
438
+ ids with no status, and the dashboard reads the front matter. A writer would have been maintaining a
439
+ document with no reader.
440
+ - **Both copies of the shape are fixed, not just the visible one.** `templates/epic.md` now points at
441
+ `03-plan/stories/<id>.md` instead of tabulating it, and the GENERATED contract the Plan sub-agent
442
+ reads (`schemaContract.ts`, spliced into the stage prompt) now says "Do NOT restate a story's status,
443
+ repo or `depends_on`" — without that, the next Plan agent invents the table again, which is how it
444
+ got there.
445
+ - `test/plan-schema-contract.test.ts` runs the issue's acceptance: build a plan from the shipped
446
+ templates, flip S1 to `done` through `updateStoryFront` (the writer the Build executor uses), then
447
+ grep the epic. Any `S<n>` + status word on one line of the epic body is a claim, and the claim set
448
+ must be empty.
449
+
450
+ - **Seven `usage` strings were narrower than the same command's `--help` (#51, after #25).**
451
+ `usage` is what a BAD invocation prints — `run.ts:85`, `questions.ts:38`, `tickets.ts:60`, `gate.ts:44`
452
+ and three more write `<cmd>.usage` to stderr — so it is the string an operator reads at the exact
453
+ moment they got the invocation wrong, and it was hiding flags the code accepts. Widened: `run attend`,
454
+ `run status`, `run estimate`, `run auto`, `run unlock` and `run cancel` now show `[--run <id>]`
455
+ (all six read `args.positionals[0] ?? stringFlag(args, "run")`); `tldrx next` shows it too
456
+ (`next.ts:48`); and `tldrx questions lint` shows the `[<run>]` positional it has always taken
457
+ (`questions.ts:49`, and `docs/guide/08-cli-reference.md` had been documenting it for longer than the
458
+ CLI admitted it).
459
+ - **The guard is subcommand-aware, and that is not gold-plating.** A plain
460
+ `usage.includes("--run")` calls `run` CLEAN, because the new `run gates set` line names `--run` —
461
+ measured, the naive check saw **one of run's seven gaps**. `test/cli.test.ts` scopes a flag that
462
+ declares a `sub:` to that subcommand's block of the usage, and falls back to the whole string for a
463
+ `sub:` that is a MODE rather than a word in argv (`dashboard --out {sub: "static"}`, which the first
464
+ draft reported as a gap it is not).
465
+ - **Three of the seven were spelling, not gaps, and are allowlisted with the reason:** `seed`'s
466
+ `<Qid> "<text>"`, `watch`'s `check <feature>` and `hook`'s enumerated script names all say the same
467
+ thing more specifically than the registry's general name. A fourth, `run`'s
468
+ `<stage>:<human|auto|agent>`, is the same case. The allowlist is itself checked: every entry must
469
+ still name a declared flag or arg, so a rename turns an exemption red instead of silent.
470
+ - **`tickets --dry-run` was left OUT on purpose.** `tickets sync` previews by default and `--apply` is
471
+ the write; advertising `--dry-run` would imply the opposite, and `test/money-safety.test.ts:319`
472
+ asserts its absence. Recorded in the allowlist as a decision rather than papered over as a gap.
473
+
474
+ - **A literal ESC byte in `McpProbe.ts`'s ANSI regex (#52).** `src/core/doctor/McpProbe.ts:11` wrote a raw
475
+ `0x1b` where `\x1b` was meant, so the source read `/<ESC>\[[0-9;]*m/g` and a reader — in a diff, in a
476
+ review, in a terminal, in most editors — saw `/\[[0-9;]*m/g`, a different and wrong-looking regex that
477
+ someone tidying is one keystroke from breaking `tldrx doctor --mcp` with. **Not the #47 hazard**, and
478
+ worth saying: ESC does not trip the binary-file heuristic, and the file was always visible to a grep.
479
+ Behaviour is byte-identical, and `.source` is the wrong instrument for proving that (it returns the
480
+ literal as written, so the two spellings differ there while compiling to the same matcher) — so
481
+ `test/doctor.test.ts` compares the shipped `stripAnsi` against a reference rebuilt from the old
482
+ literal-ESC form over a nine-line corpus, 4 of which change. `test/source-hygiene.test.ts` now flags a
483
+ raw ESC as well as a NUL: measured across all 479 `.ts` files under `src/`, `test/`, `bin/` and
484
+ `scripts/`, `McpProbe.ts` held the only one, so the check has no false positives to trade against.
485
+
486
+ - **A stray NUL byte made two source files invisible to every grep-based sweep (#47).**
487
+ `test/cli.test.ts` carried one literal `0x00`, so `file(1)` called it `data` and `grep -I` —
488
+ ripgrep and ugrep too — dropped it SILENTLY, exit 0, no message. Measured on `origin/main`:
489
+ `grep -lI -E 'node:child_process|Bun\.spawn' test/*.ts` returned **36 files with `cli.test.ts`
490
+ absent**, though it calls `Bun.spawn` on line 32. That is how it missed #43's load-aware timeout
491
+ and then timed out at 5004 ms on the very merge that was fixing timeouts. Writing the guard found
492
+ a **second** one nobody had reported — `src/core/text/srcToken.ts:711`, a NUL used as a cache-key
493
+ separator, which hid that file from every `src/` sweep (367 of 368 `.ts` files visible). Both are
494
+ now the two-character escape `\0`: identical at runtime, ordinary text on disk. Post-fix the same
495
+ sweep finds `cli.test.ts` and all 368 `src/` files. `test/source-hygiene.test.ts` walks `src/`,
496
+ `test/`, `bin/` and `scripts/` and fails on any NUL, with the offender named at `path:line`.
497
+
498
+ - **The five wave-5 docs-pass nits, each a sentence nothing was checking (#25).**
499
+ - **`boundary.ts` promised an exclusion is "never silent" and dropped state paths without a
500
+ word** — `BoundarySurface.excluded` was populated and read by nothing. Every verdict that has a
501
+ surface now names what was excluded, green and red alike, including the case where the
502
+ exclusion was ALL there was and the run therefore reported "declares no surface".
503
+ - **The precondition refusal asserted "the stage is still `ready`" without looking.** It reports
504
+ the status `run.yml` actually holds. Bigger than filed: on a FRESH run the stage at the cursor
505
+ is `pending`, not `ready`, so the old sentence was wrong in the ordinary case as well as on the
506
+ retry of a `failed` stage.
507
+ - **The agent-gate fallthrough printed its label twice** — `boundary: boundary=…`, because every
508
+ condition detail was prefixed with its own id including the two that have a trigger of their
509
+ own. Only the generic `condition` trigger keeps the prefix; alone it names nothing.
510
+ - **`dispatchNotes.ts` documented `.agent/04-build/build/S5/…`**, one phase segment more than
511
+ `dispatchNotesPath` builds. The example is now asserted equal to the path the code produces.
512
+ - **Two usage strings were narrower than their own `--help`**: `tldrx gate template` omitted the
513
+ positional `[<run>]` it accepts, and `run new` spelled `--gates <a,b|all|none>` where the help
514
+ says `<a,b|a:agent|all|none>`.
515
+
516
+ - **`tldrx next --dry-run` spawns nothing. It used to cost $0.42 a go (#17).** The flag ran the
517
+ stage for real — one `claude -p`, one `agent.spawned`, one `agent.result`, the cost on the
518
+ ledger — and only reverted the non-handoff FILES afterwards. Measured on the 2026-08-30 pilot;
519
+ `tldrx next --help` had said "Spawns nothing and writes nothing" the whole time, so this is the
520
+ code catching up to the promise rather than the promise being watered down to the code.
521
+ - **What it does now.** It assembles the prompt, prices it, and stops: the expert bundle, the
522
+ context ledger, the prompt size, the declared outputs, and the **exact `claude -p` argv** it
523
+ would have run (with the `--json-schema` blob elided as `<envelope-schema>`, rendered from
524
+ `buildClaudeArgs` itself so the printed command cannot drift from the real one). Then the two
525
+ commands that would actually dispatch it. Exit `0`.
526
+ - **Nothing is written either.** No prompt bundle and no `pending.json`, so a dry run cannot
527
+ leave a `--commit` looking at a turn that never happened; the stage keeps its status and the
528
+ ledger keeps its zero. `dry_run_allowed: false` still refuses (Build sets it: a stage that cuts
529
+ branches and fans out per-story sub-agents has no ONE dispatch to describe).
530
+ - **On an attended run it is still refused at exit `4`** — but for the right reason now. It costs
531
+ nothing; it describes a dispatch the framework never makes there, and `--prepare` writes the
532
+ bundle the host is going to carry. The message said "it spawns a real sub-agent" and no longer
533
+ lies.
534
+
535
+ - **A precondition gets its own clock, not the stage's 900–1800 s (#20).** `preconditions:`
536
+ inherited `timeout_s`, so one hung command — `docker info` against a dead daemon is the measured
537
+ case — could hold a run for half an hour: exactly the waste the feature exists to prevent, taken
538
+ by the guard instead of by the attempt. Each precondition now gets **60 s** by default
539
+ (`PRECONDITION_TIMEOUT_S`), overridable per entry with `timeout_s: <n>`, refused at load if that
540
+ is not a number `> 0`. A timeout is a red precondition like any other — exit `2`, nothing
541
+ written, nothing spawned, the stage where it was — and its message names the precondition, its
542
+ own timeout and the knob that changes it, rather than the stage's. `CommandRun` gained
543
+ `timedOut` so a timeout can be told from a refusal or a wrong exit code without reading prose.
544
+
545
+ - **The budget gate can see host-token spend and attendedness (#22).** The tolerant reader the
546
+ `budget-gate` hook and the status line share (`hooks/lib/runFile.ts`) skipped `tasks[]` and
547
+ `attended_by:` entirely. So a run whose turns a host session paid for reported `$0.00` metered
548
+ and nothing else, and `runSnapshot`'s tolerant path hard-coded `attendedByHost: false` with a
549
+ comment admitting it meant "cannot see". `RunView` now carries `attended_by` and each task's
550
+ `cost_usd` / `metered` / `tokens`; `runSpend` derives the metered dollars, the declared host
551
+ tokens and the uncosted turns; `renderRunEconomies` renders the one line that says a dollar
552
+ figure is a lower bound. The gate appends it to a `host-tokens` phase's stderr note and to a
553
+ refusal, and `budget.blocked` records `economy`, `attended_by`, `metered_usd`, `host_tokens` and
554
+ `unmetered_tasks`. **No verdict changed**: a dollar ceiling still governs dollars, the two
555
+ currencies are still never converted, and a plain metered run's refusal is byte-identical.
556
+
557
+ - **The `max_reads` flake was a real race, not a slow test (#24).** A chunk boundary is not a line
558
+ boundary: `LineSplitter` hands every complete line in one chunk to the read counter
559
+ synchronously, so when the OS coalesced the sub-agent's writes — which is what a loaded CI box
560
+ does — reads 4..20 were counted in the same tick as read 3, long before the `SIGKILL` just
561
+ ordered could land. `agent.result.payload.reads` was therefore a function of scheduling, and the
562
+ assertion pinning it to the cap cost two retries in one night. The counter now stops the moment
563
+ the cap fires, so what is recorded is the number of reads the cap ALLOWED. Pinned by a fixture
564
+ that makes the coalescing deterministic (`FAKE_CLAUDE_READS_BURST=1` — every read pair in one
565
+ write): pre-fix that reported 20 reads against a cap of 3.
566
+
567
+ - **One over-cap list item no longer cascades into false "S<id> has no file" errors (#37).**
568
+ `validatePlan` resolves cross-file references out of the set of stories that PARSED, so a story file that
569
+ failed its own validation was indistinguishable from one that was never written. Measured on the
570
+ `260829-scoring-leaderboard` session: `acceptance[3]` in `S8.md` was 1,009 characters against the 512 cap,
571
+ and the check reported three errors of which one was real — the other two said `S8 has no file in stories/`
572
+ about a file that was 5,794 bytes on disk. The operator only avoided a wasted pass by re-deriving the cause
573
+ from the validator source; an agent reading that message goes hunting for a missing file or rewrites
574
+ `waves.yml`.
575
+ - A reference to a story or epic whose FILE EXISTS is never reported as missing. It now reads
576
+ `S8 is unresolved because stories/S8.md failed validation — that file exists; fix the errors reported
577
+ against it and this one goes with them`, carries `cascade: true` on the `PlanIssue`, and covers the
578
+ id-mismatch case (`stories/S8.md declares id \`S9\``) as well as the invalid-file case. The epic side —
579
+ a story pointing at an epic whose own file did not validate — had the identical bug and the identical fix.
580
+ - `describePlanIssues` orders root violations ahead of cascades. Its window is three issues wide, so one
581
+ real defect cascading into four references could otherwise spend the whole window on consequences and
582
+ never name the cause.
583
+ - A story that is genuinely absent still reports `has no file`, with no cascade flag.
584
+
585
+ - **A refused list value now names the cap it broke, at the cap's current value (#38).**
586
+ The constants were interpolated already but the sentence was not self-describing: `513 characters exceeds
587
+ the 512 cap` did not say the cap is per-item or that splitting the item is the fix. Now
588
+ `513 characters exceeds the 512-character cap on one list item — split it into several items` and
589
+ `65 items exceeds the 64-item cap`, both still derived from `MAX_ITEM_CHARS` / the list's own `max`, and
590
+ the same constants are what the Plan prompt states up front.
591
+
592
+ - **The merge itself is now serialised, and a gate can no longer describe a tree it is not pushing
593
+ (#44).** `scripts/merge-wave.sh` merges, gates and pushes in ONE shared checkout and took no lock.
594
+ Measured on the pre-fix script with two concurrent invocations against a real sandbox repo: run A
595
+ gated `7afcc0e` (its own merge) at `typecheck` and `fadc923` (the OTHER run's merge, landed
596
+ mid-gate) at `build`, then printed `OK fadc923 … pushed`. Both runs reported the same sha and
597
+ both exited 0 — a green report over a tree neither had finished gating. With a red change in the
598
+ other branch the same interleaving hands agent A a `FAIL build=1` for code it never wrote, which
599
+ is what was actually observed live 2026-08-31 (`2184` tests counted where the branch had `2181`).
600
+ - **A lock, held from before the dirty-tree check through the push.** `mkdir` on
601
+ `.git/merge-wave.lock` — atomic on macOS and Linux, where `flock(1)` is not on stock macOS, and
602
+ in `git rev-parse --git-common-dir` so the lock can never be dirt in the tree it guards — and
603
+ not `$R/.git`, which in a linked worktree is a FILE that `mkdir` can never turn into a lock. A second invocation WAITS,
604
+ saying so on stderr (`merge-wave: waiting for another merge in this checkout (owner: …)`) so the
605
+ single summary line on stdout stays a single line. Waiting is bounded (`MW_LOCK_WAIT_S`, default
606
+ 3600 → exit `6`), and a lock whose owner is a dead pid on this host, or older than
607
+ `MW_LOCK_STALE_S`, is broken open — after re-reading the owner line, so two waiters cannot tear
608
+ down a lock a third has just taken. An interrupted run hands the lock back on its way out: an
609
+ untrapped signal kills bash WITHOUT running its `EXIT` trap, so `INT` and `TERM` are trapped too. No
610
+ path through the wait loop is free of the budget, including the break-open one: a lock that
611
+ cannot be created or removed now fails in under a second instead of spinning forever.
612
+ - **And an assertion that does not depend on the lock.** Between the last gate and the push, HEAD
613
+ must still be the commit the gates ran against; if it moved, nothing is pushed and the script
614
+ exits `5` saying which sha it gated and which one is there now. The lock prevents the race; this
615
+ makes pushing an ungated HEAD impossible even for someone who bypasses the lock. The pre-fix
616
+ script, given the same mid-gate commit, pushed it and reported `OK`.
617
+ - **It pushes the commit it gated, not the `main` ref.** `git push origin main` publishes
618
+ `refs/heads/main` whatever HEAD is — and a red gate leaves `main` sitting on an ungated merge
619
+ commit by design, so the next run from a detached or repaired HEAD would have published THAT.
620
+ The push is `HEAD:main` now, and a pre-flight refuses (exit `7`) when the gated commit is not a
621
+ fast-forward of `origin/main` rather than letting the server's rejection be the first news.
622
+ - Gate logs moved from the fixed `/tmp/mw-*.log` to a per-invocation `${TMPDIR}/mw-<pid>/`, and the
623
+ FAIL lines name the directory. Two runs in two clones on one box shared those files.
624
+
625
+ - **The test suite no longer goes red because the machine was busy (#43).** On an untouched
626
+ `origin/main`, `bun test` reported `2155 pass · 5 fail` while the same two files alone reported
627
+ `91 pass · 0 fail`: four tests that spawn a REAL `git` expiring on bun's 5000 ms default, and one
628
+ 50 ms performance budget measured at 66.4 ms, with three `tldrx` runs and two other agents sharing
629
+ the box. Because `merge-wave.sh` refuses to push on any test failure, that red is indistinguishable
630
+ from a regression at the exact moment a merge is decided, and the natural response — re-run until
631
+ green — is how a real regression eventually gets pushed.
632
+ - **The clock moved; no assertion did.** `test/fixtures/machineLoad.ts` measures the machine
633
+ (1-minute run-queue per core, floored at 1 and capped at 8) and hands out budgets from it. All
634
+ **42** test files that spawn a real process — `git`, `bun`, the CLI — now open with
635
+ `setDefaultTimeout(spawnTestTimeout())`: 30 s idle, scaled by load, still a hang detector. How
636
+ long a process takes to start is a property of the machine, not of the code, so a fixed budget on
637
+ such a test measures the box. A test enumerates those files and fails if a new one skips the
638
+ budget. Serialising the suite would not have helped: `bun test` already runs files sequentially
639
+ in one process (verified — a `setDefaultTimeout` in one file does not reach the next, and a 5.5 s
640
+ test in that next file still expired at 5000 ms). The contention is other processes on the box,
641
+ which only a load-aware budget can see.
642
+ - **The first version of that list was a `grep -l`, and it lied.** It returned 14 files and silently
643
+ omitted `cli.test.ts`, whose "every command's help lists an exit table" then timed out at 5004 ms
644
+ on the merge that was fixing timeouts. Cause: one stray NUL byte at `test/cli.test.ts:366` makes
645
+ the file `data` to `file(1)`, and grep drops binary files under `-I` without a word. The list is
646
+ built by READING every file now, and `cli.test.ts` is asserted to be in it. Filed as #47.
647
+ - **The one real performance budget keeps its teeth.** `handoff` on 256 KB is now the floor of
648
+ three runs against `perfBudgetMs(50)`, which on an idle machine is 50 — the identical assertion.
649
+ A stall inflates some runs and never the floor, and a function that genuinely takes 120 ms still
650
+ fails, which is itself a test.
651
+
652
+ - **`npm pack` output no longer refuses the next agent's merge (#45).** `tldr-experts-<version>.tgz`
653
+ was not ignored, and the dirty-tree guard refuses on ANY porcelain line, untracked included — so a
654
+ pack artifact left by a release check blocked the merge of whoever came next, someone who did not
655
+ create the file and could not know whether deleting it was safe. `*.tgz` is ignored now. The guard
656
+ is deliberately unchanged: an untracked file is still dirt, and a test holds it to that.
657
+
658
+ - **`claim-sources` reports every problem it found, over every declared `.md` output — and a
659
+ `file` src resolves against this run's epic worktree.** Four issues, one code path (#33, #34,
660
+ #23, #16), all four measured on the 2026-08-30/31 unattended pilot runs.
661
+ - **It reported ONE problem** (#33). `checkClaimSources` returned on the first file, the first
662
+ category and `unresolved[0]`, so a 226-bullet cap breach sat invisible behind a single bad
663
+ file path: fixing the visible one and re-running would have bought the next one at the price
664
+ of a full paid pass. Every file and every category is reported now, as a per-file summary
665
+ (`<file>: 3 unsourced bullet(s) on line(s) …; 2 unresolvable source(s) — …`), with file-level
666
+ problems such as the cap breach listed FIRST so 200 line numbers cannot bury them. Up to six
667
+ of a category are named and the rest become `(+N more)` — the same convention
668
+ `describeKnowledgeIssues` uses, and necessary because a check's `detail` is rendered inside
669
+ one-line summaries (`autoGate`, `next`).
670
+ - **It looked at ONE file** (#34). The filter was `endsWith("handoff.md")`, so the identical
671
+ violation refused the stage when it was written in `handoff.md` and passed in silence when it
672
+ was written in `design.md`, `contracts.md` or `scope.md` beside it — the pilot's pass-3
673
+ violation was caught only because it happened to be in the handoff. Every declared `.md`
674
+ output is read now, by both the gate check and the write-time hook: the four-section rule for
675
+ the files that ARE handoffs, and `validateCitations` for the ones that are not. That second
676
+ rule is deliberately narrower — a bullet with no citation is prose, but a `[src: …]` that WAS
677
+ written must parse, must resolve, and must obey `$ … → exit n` belonging only to an
678
+ `Evidence ledger`. A declared non-handoff output that was never written is still not a
679
+ failure; that is the `--commit` gap check's job.
680
+ - **The execution-claim validator reads the verb** (#23). `\bexit \d` missed "exits 0", which
681
+ is how a trainer writing normal English says it, so the claim slipped through the grammar the
682
+ rule exists to enforce while "exit 0" three words away was refused. Conjugation, an optional
683
+ "with", and the `code`/`status` spellings all match now; the digit is still required, so "the
684
+ exit path is documented" and "the exchange refuses an empty code" stay prose.
685
+ - **A `file` src resolves against this run's epic worktree** (#16). The Build phase commits
686
+ onto an epic branch and deliberately does not merge it, so a Watch-stage handoff ABOUT that
687
+ work had every `repo:src/…` citation refused for naming code the working tree does not have
688
+ yet — the stage's own evidence was rejected for being true. `.tldrx/worktrees/<repo>/_epic-<run>-<epic>`
689
+ is now a resolution base, tried before the working tree, for both the hook and the gate; the
690
+ path convention has one home (`core/paths.ts`) that the Build executor writes and the §2.8
691
+ resolver reads. Resolution also no longer stops at the first base where the file EXISTS but
692
+ is too short — a file truncated on the epic branch would otherwise deny a claim about the
693
+ line it still has on `main`. **Still open** (commented on #16): the epic worktree is removed
694
+ at the end of Build unless `--keep-worktrees`, so the default Watch stage has no tree on disk
695
+ to resolve against. Closing that means reading blobs out of the epic branch inside a hook
696
+ whose budget is 50 ms, or keeping epic worktrees for the life of the run — a design call, not
697
+ a mechanical one.
698
+
699
+ - **The Build DoD is a DELTA gate again: the base tree is checked before any story is charged (#41).**
700
+ A dod block proves one thing — *this story did not break the tree* — and nothing checked that the tree was
701
+ unbroken to begin with. Measured live 2026-08-31 on `260829-scoring-leaderboard`: of the three commands
702
+ `workspace.yml` declared, **two already failed on pristine main** — a bare `dotnet test` ran two `Live`-trait
703
+ tests that call paid Azure AI and that the repo's own CI excludes, and `dotnet format --verify-no-changes`
704
+ flagged 336 files in a repo whose CI never gates format at all. All 15 stories in the plan would have blocked
705
+ identically, each having spent a developer turn, and each told the operator the STORY was red.
706
+ - **Pre-flight at Build entry.** After the dirty-tree and foreign-epic refusals and before anything is
707
+ dispatched, every dod command the pending stories name is run once against the untouched base tree. A
708
+ non-zero exit refuses the stage (exit `2`, back to `ready`) naming the command, its exit code and the repo,
709
+ with no attempt spent and nothing charged.
710
+ - **In the repo's own checkout, not a fresh worktree.** That is the tree a human means by "the base": it has
711
+ the installed dependencies and tool state that make the command mean what the team thinks it means. A
712
+ pristine worktree would fail half the world's repos for want of `node_modules` and turn a safety net into
713
+ an outage.
714
+ - **Paid for once.** Results go to `04-build/preflight.yml` — files are the state — keyed by repo, command and
715
+ the base sha, and are read back by every later invocation of the run. A missing or unreadable cache is a
716
+ question, never a fault: a run that entered Build on an older binary measures lazily rather than erroring.
717
+ - **Attribution.** When a story's DoD does go red, the cached base result decides whose fault it is. A command
718
+ red on the base too halts the build with the same workspace-config error rather than blocking the story and
719
+ consuming its attempt. A command the gate declined to run is recorded `unmeasured` and excuses nothing.
720
+
721
+ - **The review handshake no longer swallows an unrecognized verdict, nor drops structured
722
+ findings.** Measured on `260831-hardening-d1` / S1 (2026-08-31). Two verdict grammars coexist
723
+ — gate evidence is `sign | sign-with-fixlist | refuse`, a story review is
724
+ `approve | fixlist | changes` — and the host-facing hint named neither, saying only "write
725
+ {verdict, summary, findings}". The host wrote `sign`. `parseReview` fail-closed it to `changes`,
726
+ correctly and **silently**: a clean fix-list verification round read as a second `changes`, the
727
+ story went `blocked`, and a `story reopen` cycle was the only way to record the verdict that
728
+ had been meant all along. Separately, `findings` was filtered with `typeof f === "string"`, so
729
+ the attempt-1 adversarial reviewer's seven `{severity, file, line, claim, evidence, fix}`
730
+ objects were dropped whole — the verdict survived, the evidence it rested on did not.
731
+ - **The contract is now stated where the host reads it.** Both `--commit --review` hints name
732
+ the enum: `verdict is one of approve | fixlist | changes, NOT the `sign`/`refuse` gate
733
+ vocabulary`.
734
+ - **The downgrade is announced.** Fail-closed is unchanged — an unreadable verdict is still
735
+ `changes`, never `approve` — but a verdict outside the enum now comes back on
736
+ `Review.verdictProblem` ("the reviewer's verdict `sign` is not approve|fixlist|changes —
737
+ recorded as `changes`"), is printed by the executor on the one path both doors pass through,
738
+ and rides in `findings` so the review log and the next attempt's `## Previous attempt` both
739
+ carry it. A DECLARED `fixlist` that fell short is untouched: `fixlistProblems` already says
740
+ that one out loud, and two sentences for one downgrade would read as two faults.
741
+ - **Structured findings are rendered, never dropped.** An object becomes
742
+ `[severity] file:line — claim · evidence: … · fix: …`; a shape nothing recognizes is kept as
743
+ JSON; a `findings` that is not an array is kept as one finding. An unreadable finding in the
744
+ log beats a finding that is not in the log.
745
+
746
+ - **A project stage override that supplies only `stage.yml` no longer swaps the stage body for an
747
+ empty one.** Reported from the 260829-scoring-leaderboard driver session (2026-08-31) and
748
+ reproduced here: `stage.md` was resolved by string-substituting `stage.yml` in the path the
749
+ preset had already picked, so creating `.tldrx/stages/plan/stage.yml` to tune one key moved the
750
+ BODY lookup into a directory that had none — and the miss was read as an empty string. The
751
+ context ledger printed `stage 1 B` where it had been 4.9 KB; the sub-agent would have been
752
+ dispatched with the inputs, the experts and the rejection note and **zero** stage instructions,
753
+ and nothing refused. `stageMdPath` now resolves per FILE, not per directory: the override's own
754
+ `stage.md` wins, else the packaged one is inherited, and a stage with no body anywhere is a
755
+ named `StageBodyError` rather than a silent empty prompt. Both readers — `next --prepare` and
756
+ the Watch executor — go through it.
757
+
758
+ - **`approve --as-agent`'s refusal now names the route that works on the run in front of you.**
759
+ It pointed only at `--gates <stage>:agent`, which is chosen at `run new` and frozen there — so
760
+ the one suggestion meant recreating a run already in flight. It now leads with the delegated
761
+ approve: read the agent's evidence note yourself and sign as you, `tldrx approve --note
762
+ "delegated: <agent> reviewed this, evidence at <path>"`, which keeps the gate's policy and puts
763
+ the provenance on the record. Found across the 2026-08-30/31 unattended pilots.
764
+
765
+ - **`budget raise <phase> <usd>` help said `<usd>` was "the new ceiling"; the source adds it.**
766
+ `raiseBudget` computes `ceiling_usd + amount` (`raiseBudget.ts:83`), so an operator following
767
+ the help over-raised — measured live on the scavtopia leaderboard run, a "$5.40 new ceiling"
768
+ command would have set $8.00. The arithmetic is what live runs depend on and is untouched; the
769
+ words move. `<usd>` is now "how much to ADD to that phase's ceiling — a delta, not a new
770
+ ceiling", with a note spelling out the $10 + $25 = $35 case and pointing at `budget show`, which
771
+ already prints the correctly-sized command.
772
+
773
+ - **`tldrx run new --from` stores an imported answer's own words, not a letter pointing at a file
774
+ it does not own.** AI-DLC records a chosen option as `[Answer]: C`, and the import stored
775
+ "<question> — C" verbatim; two facts became unreadable once aidlc was uninstalled and the source
776
+ file went with it (2026-08-30/31 pilots). The interview flow has always resolved a letter to the
777
+ option's text before recording (`interview/reply.ts:32-37`), and the import now does the same:
778
+ `parseAidlcQuestions` reads the lettered options (uppercase, and a space required after the
779
+ punctuation, so `- E.g. …` stays prose) and `answerText` resolves the answer against them; a
780
+ letter with no option behind it is stored as typed rather than invented. Conflict detection is
781
+ unchanged by the longer text: it keys on the QUESTION, as `hooks/no-reask.ts:54` already does,
782
+ because `findDuplicate` is Jaccard over tokens and therefore length-sensitive — the same
783
+ contradiction scored 0.78 against a bare letter and ~0.22 against the answer written out.
784
+
785
+ - **A second run's stories no longer merge into ANOTHER run's epic branch.** Measured live
786
+ 2026-08-31 on two concurrent runs: `260831-hardening-d1` reported S1, S2 and S6 all
787
+ "merged into `epic/hardening-d1` (N commits carried)", and `epic/hardening-d1` was still
788
+ sitting at its base with **zero** story commits — all three merges had landed on
789
+ `epic/d1-tenancy-identity-customers`, a CLOSED previous run's branch. Nothing failed; the
790
+ run closed green with an empty epic, and it surfaced only because a later story measured
791
+ `git merge-base` and found the dependency it had been promised was missing.
792
+ - **The cause was one missing run id.** `openEpicWorktree` built the epic worktree's disk
793
+ path as `_epic-<epic id>`, and every plan names its first epic `E1`. The second run's
794
+ `existsSync` therefore hit the FIRST run's directory, `addWorktree` was skipped, and
795
+ `git merge --no-ff` ran inside a checkout of a foreign epic branch. The in-memory map was
796
+ keyed correctly (`repo:epicBranch`) — only the path collided, and only across processes.
797
+ Every progress line renders `story.epicBranch`, so the messages were right about where the
798
+ merge was *meant* to go for as long as the bytes went somewhere else.
799
+ - **The path now carries the run**: `_epic-<run id>-<epic id>`, the same shape the STORY
800
+ worktree was given after the 2026-08-29 audit found the identical class of bug one level
801
+ down. That fix never reached the epic worktree, which is the worse half — a story worktree
802
+ collision means two sub-agents editing one file, an epic worktree collision is a merge.
803
+ - **And a reuse on the wrong branch now refuses.** Every reuse of an epic worktree — the
804
+ remembered path and the one found on disk — asserts its checked-out branch is the story's
805
+ epic branch first (`assertWorktreeOn`, `core/build/git.ts`). A mismatch throws
806
+ `WorktreeBranchMismatchError` naming both branches and the directory, and fails the stage.
807
+ It never re-points the worktree and never merges anyway. Path scoping makes the collision
808
+ impossible; this makes it impossible to repeat *silently*.
809
+
810
+ - **A `--note` with a blank line in it no longer destroys `run.yml`.** Measured 2026-08-31 on the
811
+ live `260829-scoring-leaderboard` run: `tldrx reject --note "<two paragraphs>"` wrote the note
812
+ into the gate's flow mapping with LITERAL newlines inside a double-quoted scalar, which is not
813
+ YAML — the `yaml` package answered `Missing closing " quote at line 57` and Bun's parser
814
+ `Unexpected character` — and from that moment **every** command on the run failed. There was no
815
+ repair verb, so the operator had to hand-edit a file the docs forbid editing, and the next save
816
+ re-emitted the same string and broke it again at the same line, taking `run.yml.bak` with it.
817
+ Four changes, each closing one part of the loop:
818
+ - **The emitter escapes, at the one place every field goes through.** `yamlScalar`
819
+ (`core/facts/emitFactsYaml.ts`) escaped `\` and `"` and nothing else; it now emits via
820
+ `JSON.stringify`, whose string grammar is a strict subset of YAML 1.2's double-quoted scalar —
821
+ the escaping this repo already trusted in `adapters/external.ts` and `build/storyFile.ts`.
822
+ Because every YAML this framework hand-emits routes strings through that one helper, the fix
823
+ reaches **all** of them at once: gate notes (`approve`, `reject`, `revoke`), `cancelled.note`,
824
+ task `error` and `stopped_by`, gate `evidence`, run `title`/`scope`, `facts.yml` fact text and
825
+ retirement reasons, and `split.yml` goals, claims, questions and answers. Verified against
826
+ **both** parsers behind the runtime seam. Existing files do not churn: over every code point
827
+ from U+0020 to U+FFFF the new escaping and the old produce identical bytes (63,456 checked, 0
828
+ differ), so only the values that were already corrupt change shape.
829
+ - **A file already broken this way heals itself on load.** `parseYamlRepairing` (`core/yaml.ts`)
830
+ re-escapes raw control characters trapped inside a double-quoted scalar, re-parses, and accepts
831
+ the result only if it parses — otherwise the parser's ORIGINAL error is thrown, because a
832
+ repair that cannot be verified is not offered. `RunStore.open` then rewrites the mended file
833
+ through the fixed emitter and says so on stderr. A one-time hand repair was never enough: the
834
+ old emitter re-corrupted the file on the next save, so `emit(load(x))` had to be made stable.
835
+ - **Every state write keeps one step back.** `RunStore` and `FactsStore` had grown a
836
+ byte-identical private copy of temp-plus-rename each; both now call one
837
+ `core/fs/writeAtomic.ts`, which additionally copies the version it is about to replace to
838
+ `<file>.bak`. Atomicity only ever guaranteed a WHOLE file, never a good one. The copy is taken
839
+ before the rename, so the live file is never absent for an instant and a torn backup can only
840
+ ever cost a backup. `tldrx init` now adds `tldrx-work/*/*.bak` and `.tldrx/memory/*.bak` to the
841
+ managed `.gitignore` block.
842
+ - **A `run.yml` beyond mechanical repair fails honestly, and takes nothing else down with it.**
843
+ The error names the file, quotes the parser verbatim, says that every command on the run reads
844
+ that file first, and points at `run.yml.bak` — while stating plainly that using it is a MANUAL
845
+ decision tldrx will not make. Separately, one corrupt `run.yml` used to throw a raw
846
+ `YAMLParseError` out of `buildModel` and kill `tldrx dashboard` for the whole workspace;
847
+ `loadRunResult` (`core/replay/loadRun.ts`) now distinguishes missing from unreadable, and the
848
+ dashboard lists the run as **unreadable** with the parse error beside it and renders every
849
+ other run as normal.
850
+
851
+ ### Changed
852
+
853
+ - **The docs now say, at the top of both places a reader starts, that `run attend host` is a LOCK
854
+ and `run auto` is an ENGINE.** Grounding: on 2026-08-31 the framework's own author — who had read
855
+ the chapter — ran `tldrx run attend host <run>` expecting it to drive the whole run by itself,
856
+ and then asked whether `attend` and `auto` compose. They do not, and the code has always said so
857
+ (`runAuto.ts:108` refuses `run auto` on an attended run at exit `1`, before the event log is
858
+ opened; `runNext.ts:659` exits `4` on a bare `next` and names the `--prepare` command). The docs
859
+ took too long to say it.
860
+ - **README gains "Trying it: three ways to run"**, immediately after Quick start: a three-row
861
+ table of who executes each turn, what a turn costs, and where each mode stops; one scenario
862
+ line each (`run auto` for a small run you would watch anyway and for CI/cron — the only mode
863
+ with no session behind it; `attend host` when a session is already open and cost or quality
864
+ matters; `attend host` + a mandate for overnight); and the two-command recipe with a verbatim
865
+ example **mandate prompt**.
866
+ - **`docs/guide/10-unattended-mode.md` leads with the same disambiguation** — a blockquoted
867
+ lock-vs-engine table above the chapter's opening paragraph, so a skimmer cannot make that
868
+ mistake — and gains a `### The mandate` section carrying the prompt verbatim, tying its four
869
+ legitimate interrupts back to the `questions` / `budget-event` / `boundary` fallthroughs the
870
+ framework already enforces, and to the fact that no `git push` wrapper exists in the Build
871
+ executor (`src/core/build/git.ts:13`) and the developer prompt says "Do not push"
872
+ (`src/core/build/prompts.ts:180`).
873
+ - **`tldrx run --help` says it too.** `run` had notes for `status`, `estimate`, `unlock` and
874
+ `cancel` and none for the pair that actually confuses people. It now leads with one note per
875
+ mode — "a LOCK, not an engine" / "an ENGINE, not a lock", each naming the other's refusal — and
876
+ the `<host|--none>` argument line says the framework will not spawn on the run again. Help text
877
+ only; no behaviour, no flag and no exit code moved.
878
+
879
+ - **`tldrx run estimate` is remaining-work aware (#21).** It priced the next stage from token medians while
880
+ the budget brake separately computed what that stage still had to pay for — two models, one question, and
881
+ the one people read was the one that never shrank. A Build stage with five of six stories done was still
882
+ quoted the number the Plan wrote before any of them ran, which is the figure that made a pilot operator
883
+ move money twice for work the run could already afford.
884
+ - It now calls **the same `remainingWork()`** the brake and `budget show`'s `est.` column call, with the
885
+ same inputs, and reports it beside the token estimate: done stories excluded, blocked ones named, the
886
+ arithmetic shown. A test asserts the two numbers are identical rather than merely similar.
887
+ - It also rolls the run up: `still to run: N stage(s) … $X priced`, with terminal stages excluded and the
888
+ cursor stage narrowed by the plan when the plan knew better. `--json` carries both as `remaining` and
889
+ `runRemaining`.
890
+ - The token half is untouched. The input side is still measured off the same assembly `next` builds, and
891
+ the cache/output medians still say which sample they came from — that half was never the complaint.
892
+
3
893
  ## 0.3.1 — 2026-08-31
4
894
 
5
895
  **Unattended mode.** Twelve of the entries below are one feature: a run a **host session**
@@ -653,6 +1543,66 @@ none of these behaves byte-identically to the release before them.
653
1543
 
654
1544
  ### Fixed
655
1545
 
1546
+ - **A trainer that `cd`s no longer writes its knowledge file into a different git repo.**
1547
+ Measured 2026-08-31 on `~/scavtopia` (five repos, ten `expert train --mode light` runs): the
1548
+ `mcp` run was rejected with `mcp.md.partial was never written`, and the file had been written —
1549
+ 46 lines, 9,567 bytes, complete and usable — to
1550
+ `whiteboard/.tldrx/experts/mcp/knowledge/mcp.md.partial`. The sub-agent ran
1551
+ `cd <workspace>/whiteboard` to execute that repo's declared gate command, then wrote the
1552
+ RELATIVE path the prompt had given it, and the path resolved against the repo it had `cd`'d
1553
+ into. Three costs from the one bug: **$1.23 charged for work that was finished and then
1554
+ orphaned**, a parasitic `.tldrx/` tree left inside an unrelated git repo (`git -C whiteboard
1555
+ status` → `?? .tldrx/`), and **no repair round possible** — the missing-file branch returns
1556
+ before the repair check, so this failure mode was unrecoverable by construction even with
1557
+ budget left. Fixed at both ends.
1558
+ - **Prevention: the prompt now states an ABSOLUTE output path**, workspace-root-resolved, and
1559
+ says why — "If you `cd` into a repo to run its gate command, a relative path then resolves
1560
+ against THAT repo … and throws the whole paid run away. That is measured, not hypothetical."
1561
+ Both training prompts carry it, and so does the repair round's target, for the same reason.
1562
+ - **Recovery: when the file is missing, the declared repo roots are probed** for the stray
1563
+ relative write before "never written" is said. A file found there is moved back and validated
1564
+ exactly as if it had landed correctly — recovery is not a pass, the same `parseKnowledgeFile`
1565
+ still judges it, and a recovered file that fails can still be repaired because the probe runs
1566
+ ABOVE the repair round.
1567
+ - **The note is honest and names the mess.** `recovered: the trainer wrote to
1568
+ whiteboard/.tldrx/… , inside the `whiteboard` repo — a relative `.tldrx/…` path resolves
1569
+ against whatever directory it had `cd`'d into.` The empty parasitic directories are removed
1570
+ on the way out; a directory holding anything else is **left in place and named**, with the
1571
+ `git -C <repo> status` to run, because a tool that deletes inside a repo it was never asked
1572
+ to touch is a worse bug than the one it is fixing. A repo carrying its own
1573
+ `.tldrx/workspace.yml` is skipped entirely — that file may belong to a nested workspace, and
1574
+ taking it would be theft rather than recovery.
1575
+ - When no stray is found the verdict is unchanged and now says where it looked.
1576
+
1577
+ - **A rejected training run records WHICH problems, not just how many.**
1578
+ Measured 2026-08-31: `components` failed with 12 problems for $1.02, and `training.jsonl` — the
1579
+ durable record — held only the string `"…does not validate — 12 problem(s)"`. The twelve went
1580
+ to stdout, where five of them were printed and the rest elided as `(+7 more)`. Anyone who had
1581
+ not captured stdout, which is anyone running this normally, could not tell why a $1.02 run
1582
+ failed. The list is now persisted twice.
1583
+ - **On the ledger**: `check.failed.payload` carries `problems` (the rendered per-problem
1584
+ lines), `problems_total`, `errors`, and `task`. The list is fitted to the record's 4 KB
1585
+ payload cap and reports `problems_omitted` when it does not fit — an append that THROWS on
1586
+ an oversize payload would take the cost line down with the reasons, which is the opposite of
1587
+ the point. The repair round's own `check.failed` carries what it sent back, so "what did the
1588
+ repair actually fix" is answerable later.
1589
+ - **In the file**: `<area>.rejected.md` now opens with a `# REJECTED` header — expert/area,
1590
+ mode, timestamp, dollars spent, error and warning counts, and every problem, uncapped —
1591
+ above the trainer's bytes exactly as written, separated by a rule. A quarantine with no
1592
+ verdict (a sub-agent that died, a rollback) gets no header: there were no reasons to state
1593
+ and inventing them would be inventing the reason.
1594
+
1595
+ - **`## Sources` is now taught as prose with the refused shape shown.** Same batch: four of the
1596
+ five problems the `components` report printed are one mistake four times — `L34 Sources: no
1597
+ [src: …] token`, `L35`, `L36`, `L37`. The trainer had written the recap as a bulleted list.
1598
+ The prompt already said "**Sources** — prose", and a writer who reads that as a style note
1599
+ writes bullets, because bullets are what the other four sections take. The rule it collides
1600
+ with is genuinely file-wide — `parseKnowledgeFile` requires a `[src: …]` token on EVERY list
1601
+ item in every declared section, recap included, and an unsourced one is an error that rejects
1602
+ the file whole. Both prompts now show the accepted prose next to the refused bullets, the same
1603
+ move the execution-claim rule makes. Whether an unsourced recap bullet should be a warning
1604
+ rather than an error is a real question and is deliberately NOT settled here.
1605
+
656
1606
  - **A rejected knowledge file gets ONE repair round before the money is thrown away.**
657
1607
  Measured 2026-08-30 on `~/scavtopia`: `tldrx expert train dotnet-stack --area dotnet --mode
658
1608
  light` spent **$1.69**, the trainer wrote `knowledge/dotnet.md.partial`, and the validator
@@ -985,6 +1935,19 @@ none of these behaves byte-identically to the release before them.
985
1935
 
986
1936
  ### Verified, not changed
987
1937
 
1938
+ - **`tldrx expert train` already exits nonzero when a training fails.** The 2026-08-31 batch
1939
+ report measured shell `EXIT=0` on all ten invocations, including the three that failed their
1940
+ check — but nine of those ten ran on a build that predates this one (`dist/tldrx.js` was
1941
+ rewritten mid-batch at 05:40Z). On the current source the code path is intact:
1942
+ `runTraining` returns `EXIT_AGENT_FAILED` (5), `expert train` returns `outcome.code`,
1943
+ `dispatch` returns it, and `bin/tldrx.ts` does `process.exit(await dispatch(...))`. Now pinned
1944
+ by three tests that drive the REAL CLI as a subprocess with a fake `claude` on PATH and assert
1945
+ the PROCESS exit code — one for a file that does not validate, one for a file that was never
1946
+ written, one for the passing case — because "`runTraining` returns 5" and "the process exits
1947
+ 5" are two different claims. Falsified before being trusted: making `expert train` return
1948
+ `EXIT_OK` breaks two of the three.
1949
+
1950
+
988
1951
  - **The walk already skips vendored and generated trees**, and always did: `SKIPPED_DIRS`
989
1952
  in `detect/walk.ts` covers `node_modules`, `dist`, `build`, `out`, `bin`, `obj`,
990
1953
  `target`, `.venv`, `Pods`, `.next`, `.expo`, `coverage` and more, plus every