tldr-experts 0.4.0 → 0.6.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 (48) hide show
  1. package/CHANGELOG.md +2225 -0
  2. package/README.md +78 -16
  3. package/dist/hooks/answer-capture.js +136 -13
  4. package/dist/hooks/budget-gate.js +14 -11
  5. package/dist/hooks/{chunk-t1ywrfr4.js → chunk-17gv74sr.js} +10 -8
  6. package/dist/hooks/{chunk-s1c5h7yx.js → chunk-5k4dggq0.js} +72 -19
  7. package/dist/hooks/{chunk-4cp363kv.js → chunk-7eqhfddw.js} +81 -578
  8. package/dist/hooks/{chunk-rpcxsqh3.js → chunk-97ncnegd.js} +1 -1
  9. package/dist/hooks/{chunk-sq44k6g2.js → chunk-9w64mxdh.js} +1007 -3
  10. package/dist/hooks/{chunk-9gb21660.js → chunk-dhbxzjfs.js} +52 -14
  11. package/dist/hooks/{chunk-phmdk72a.js → chunk-g4db8rbs.js} +2 -30
  12. package/dist/hooks/chunk-h7151g8w.js +803 -0
  13. package/dist/hooks/{chunk-7y2dq0pj.js → chunk-ka6bkb64.js} +1 -1
  14. package/dist/hooks/chunk-rrkdfk7s.js +30 -0
  15. package/dist/hooks/{chunk-c6t5nx0r.js → chunk-v4ay50nq.js} +1 -1
  16. package/dist/hooks/{chunk-b8kxzna2.js → chunk-vrhczzxs.js} +1 -1
  17. package/dist/hooks/{chunk-tzzwddct.js → chunk-x6n8b5fp.js} +1 -1
  18. package/dist/hooks/claim-sources.js +21 -20
  19. package/dist/hooks/dod-gate.js +7 -6
  20. package/dist/hooks/no-reask.js +8 -9
  21. package/dist/hooks/session-start.js +58 -28
  22. package/dist/hooks/statusline.js +10 -11
  23. package/dist/tldrx.js +8491 -3059
  24. package/env.yml +9 -2
  25. package/package.json +2 -2
  26. package/plugin/.claude-plugin/plugin.json +2 -2
  27. package/plugin/skills/tldrx/SKILL.md +9 -2
  28. package/stages/watch/stage.md +4 -0
  29. package/templates/expert.md +13 -1
  30. package/templates/questions.md +8 -0
  31. package/templates/watcher.md +8 -1
  32. package/workflows/bugfix.yml +8 -4
  33. package/workflows/docs.yml +8 -4
  34. package/workflows/feature.yml +8 -4
  35. package/workflows/hotfix.yml +8 -4
  36. package/workflows/integration.yml +8 -4
  37. package/workflows/migration.yml +8 -4
  38. package/workflows/performance.yml +8 -4
  39. package/workflows/prototype.yml +8 -4
  40. package/workflows/refactor.yml +8 -4
  41. package/workflows/security-patch.yml +8 -4
  42. package/workflows/spike.yml +8 -4
  43. package/workflows/upgrade.yml +8 -4
  44. package/dist/hooks/chunk-9zsqxr6y.js +0 -213
  45. package/dist/hooks/chunk-m3mewgnw.js +0 -522
  46. package/dist/hooks/chunk-rz541e2b.js +0 -204
  47. package/templates/epic.md +0 -40
  48. package/templates/story.md +0 -55
package/CHANGELOG.md CHANGED
@@ -1,5 +1,2230 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0 — 2026-09-02
4
+
5
+ ### Added
6
+
7
+ - **A gate record now says which entity evaluated it and under whose authority (#122).**
8
+ Measured 2026-09-02 on run `260902-discovery-pipeline-map`:
9
+ `{"type":"gate.approved","actor":"alanmartinez","payload":{"by":"alanmartinez","note":"agent-gate: evidence=sign by alanmartinez, …"}}`
10
+ — a gate an AGENT evaluated and signed, under authority the owner delegated once at
11
+ `run new --gates what:agent`. Nobody named `alanmartinez` looked at that stage. `by:` is a
12
+ name and never a kind, the agent signs under the operator account it is running as, and six
13
+ months later that record reads as "Alan personally reviewed this". The only trace of the
14
+ delegation was the prose inside `note:` — which nothing parses and any hand-typed
15
+ `--note "agent-gate: …"` can forge.
16
+ - Two additive blocks on `run.yml`'s gate mapping and on the `gate.approved` payload:
17
+ `executed_by: {type: human|agent|auto, id?}` and
18
+ `authority: {type: direct|delegated, policy, authorized_by, source}`. Between them they
19
+ answer the four questions an audit asks — who authorized the decision authority, which
20
+ entity evaluated THIS gate, whether it was a person / an agent / the facilitator, and
21
+ under which policy.
22
+ - **The executor's kind is read off how the gate is being closed, never off the policy.**
23
+ A person may always `approve` an `agent`-gated stage with no flag; that is a human acting
24
+ directly, whatever the stage was set up to allow, and it is recorded as one.
25
+ - **Derived, never guessed.** The policy is the run's own frozen `gates_policy`. The
26
+ authorizer is the actor of the `gate.policy_changed` that last moved that stage's policy,
27
+ or — when nothing moved it — the actor of `run.created`, who froze it at `run new`, and
28
+ `source` names which. When the log says neither, `authorized_by` is `null` and `source` is
29
+ `unrecorded`: the absence is said out loud rather than filled in with a plausible name,
30
+ and the validator refuses a record where those two do not travel together.
31
+ - **`by:` is untouched**, and so is `gate.evidence`. `by` is what the note said, and the
32
+ note is the agent's own claim about itself; rewriting it would be inventing a second one.
33
+ `gate.evidence` says what was checked — not who checked it under whose authority — and
34
+ its `role:` is a job an agent gave itself, not an identity the framework measured.
35
+ - **Old records read exactly as they did.** A gate written before these keys has neither,
36
+ validates, loads, and emits byte-for-byte; every reader falls back to `by`, and so does a
37
+ person who signed as themselves — for them the name is the whole truth. Pinned by test in
38
+ `test/gate-authority.test.ts`.
39
+ - `tldrx run status`, `tldrx replay` and the dashboard render a delegated signature as
40
+ `agent alanmartinez (delegated by alanmartinez, policy: agent)` — one shared renderer,
41
+ `describeGateSignature`, so the three cannot disagree. The dashboard carries its own
42
+ closure-free copy (`dashSignature`) because everything serialised to the browser may close
43
+ over nothing, and the two are asserted to agree case for case, exactly as `dashEscape` is
44
+ against `escapeHtml`. `DASHBOARD_MODEL_VERSION` stays at **3**: additions never bump it.
45
+ - A revoked gate DROPS both. `by: null` says nobody signed it, and an executor left beside
46
+ that would be two contradicting claims about one fact.
47
+
48
+ - **`tldrx run status` prints a stage's duration and says when a gate was signed with
49
+ words (#120).** `run.yml` has recorded `started_at`, `ended_at` and `gate.note` on every
50
+ stage since `run new` wrote the first one. #118 put all three on the dashboard's
51
+ `StageRowModel` and drew them; `runStatus.ts` built its own record from the same file and
52
+ was not touched, so the page and the CLI disagreed about what is knowable from one file —
53
+ a reader watching a stage that had been running for hours saw no sign of it in the
54
+ terminal. Each gate row (one per stage) now ends with a compact duration and a `✎` on a
55
+ signed note, and `--verbose` adds the two instants behind a duration, the sentence behind
56
+ an absent one, and the note itself.
57
+ - **One subtraction, not two.** `dashDuration` / `dashDurationAbsence` moved out of
58
+ `dashboard/render.ts` into `core/run/duration.ts`, a leaf that imports nothing and that
59
+ both surfaces read — rather than a second implementation of the same arithmetic. They
60
+ keep their `dash` names because `clientRenderer()` serialises the DEFINITION name into
61
+ the page, so a rename would be a `ReferenceError` on the live dashboard rather than a
62
+ build error; both are still closure-free and still serialised.
63
+ - **Still no stored duration, and still no synthesised zero.** A stage with a `started_at`
64
+ and no `ended_at` reads `not ended`, one with the reverse reads `no start`, and two
65
+ timestamps that do not yield a gap read `bad timestamps`. A stage with NEITHER end says
66
+ nothing on the line — it has no clock to account for and its status column already says
67
+ `pending`; printing "not timed" on every row of a fresh run is noise, not honesty, and
68
+ `--verbose` still names that case in full. `note: ""` is not a signature: it reads as
69
+ `null`, is never marked and is never quoted.
70
+ - **`--json` is additive.** `started_at`, `ended_at` and `note` are appended to each gate
71
+ row; every top-level key keeps its position, so a consumer reading `run`, `waiting.kind`
72
+ or `gates[i].by` is untouched. A new test pins the row's key order the way
73
+ `SINGLE_RUN_KEYS` pins the top level's.
74
+
75
+ - **The docs site's demo dashboard now shows the story grid and the Waves view with
76
+ something in them (#119).** `gen-demo.ts` composed its workspace from two fixtures,
77
+ neither of which had ever reached the Plan phase, so `loadPlan` returned null for all
78
+ eight runs and every plan-shaped rendering on the public page drew its EMPTY state: the
79
+ Waves view said "No waves in this workspace", the story grid drew nothing at all, and
80
+ Plan & build said the Plan phase had written no stories. A demo of a dashboard that
81
+ cannot show two of its own views is a demo of the wrong thing.
82
+ - A **third** synthetic fixture — `test/fixtures/plan/workspace`, one run at Build with
83
+ six stories over two waves, two epics on two repos, a fix round on S2 and two review
84
+ retries on S5 — added to `DEMO_SOURCES` beside the other two. Third rather than an
85
+ edit, because a `03-plan/` dropped into a chain run would move that run's
86
+ `stagesTotal`, `stagesDone` and `percent` (numbers `dashboard-deps.test.ts` reads),
87
+ and `dashboard.test.ts` asserts — correctly — that the views fixture's one run carries
88
+ `plan: null`. Both existing fixtures stay byte-identical.
89
+ - Synthetic like everything else on that page: it lives under `test/fixtures/`, so
90
+ `assertSynthetic` is what permits it to be read at all, and the demo stays
91
+ deterministic (fixed clock, invented root, no machine path).
92
+ - Measured over the composed workspace: 9 runs, 2 experts, 0 unreadable files, 0 skipped
93
+ events, 6 stories across 2 waves, and `run.build` non-null for the first time.
94
+
95
+ - **A stage now carries when it started, when it ended and what its gate said (#118).**
96
+ `run.yml` has recorded `started_at`, `ended_at` and `gate.note` on every stage since
97
+ `run new` wrote the first one, and `StageRowModel` carried none of the three — so the
98
+ phase timeline (#107) printed neither a duration nor a signature and had to carry a
99
+ paragraph explaining why. Three additive fields, read off the same `run.yml` object the
100
+ row was already built from. `DASHBOARD_MODEL_VERSION` stays at **3**: additions never
101
+ bump it, and no existing field reads differently than it did at v3.
102
+ - **No duration is stored.** A duration is a subtraction, it exists only when both ends
103
+ do, and a model field would have to pick a number for the case where one end is
104
+ missing — `0` is a measurement of zero, and inventing one is exactly the class of
105
+ confident-wrong figure this redesign exists to stop. `dashDuration` does the
106
+ subtraction where it is drawn, and a stage that recorded neither end gets a sentence
107
+ naming *which* end is missing rather than a blank cell reading as "it took no time".
108
+ - **An empty note is an absent one.** `note: ""` is what `run new` writes on a gate
109
+ nobody has signed. It reaches the model as `null` and is not quoted at a reader as if
110
+ it were a signature.
111
+ - The timeline draws the duration beside the cost on each stage's summary row and quotes
112
+ the gate's own words inside its drawer — the two of #107's four asks that could not be
113
+ met before.
114
+ - **`tldrx dashboard --serve` pushes only when the page would actually change, and ages
115
+ itself when it would not (#108).** The live server already watched the tree and pushed a
116
+ `reload` per debounced burst; three things were missing, and each one is a different way
117
+ for a live page to lie.
118
+ - **A push per burst is not a push per change.** Every write under `.tldrx/` or
119
+ `tldrx-work/` pushed, including writes to files the model does not read — `--static`
120
+ writes into `.tldrx/cache/`, inside the watched tree, so exporting a page while serving
121
+ one made the served page redraw because it had been photographed. The trigger is still
122
+ the file event; the DECISION is now a rebuild of the model compared against the one the
123
+ page is showing, on a fixed clock so ages do not make every rebuild differ. A digest of
124
+ "the files that matter" was rejected on purpose: that list drifts the moment the model
125
+ reads one more thing, and its failure mode — a dashboard that quietly stops updating —
126
+ is exactly what the comparison cannot do.
127
+ - **Silence is a state the files cannot announce.** Ages, and the `quiet` mark #107 puts
128
+ on half an hour of nothing, are computed against a `now`. With only file-triggered
129
+ pushes a stalled run keeps saying "2m ago" until somebody writes a file: the one state
130
+ worth seeing is the one state the page could not reach. A new `age` event carries a
131
+ timestamp every 25 s, whatever the disk is doing, and doubles as the stream's
132
+ keep-alive — a tick that says something, at the cost of the comment that said nothing.
133
+ - **The ledger is read forward.** `events.jsonl` is append-only, so
134
+ `src/core/dashboard/tail.ts` keeps a byte offset per run and reads only what arrived —
135
+ holding back a line whose newline has not landed, starting over if a ledger shrinks
136
+ under it, and dropping a run's offset when the run goes. That is what lets a `reload`
137
+ carry `{at, appended, runs, added, removed}` and say *three events landed on this run*
138
+ rather than only *something changed*.
139
+ - **`--serve` is a flag now**, and `--serve --static` is a refusal (exit 2) rather than a
140
+ silent pick of one. Serving is still the default.
141
+ - **`listRuns` no longer throws while the disk is being written to.** An entry removed
142
+ between the `readdir` and its `stat`, and a `tldrx-work` that is not a directory, both
143
+ escaped `buildModel` — measured, both reproduced. A live reader asks that question
144
+ while a wave is writing.
145
+ - The live page keeps the reader's FOCUS across a repaint (scroll and open panels were
146
+ already kept), because only a live page redraws when nobody asked, and #107's j/k
147
+ navigation loses its place otherwise. That code is in `liveScript()` alone: the static
148
+ export is byte-identical to what it was before this landed, and a test pins the hash.
149
+
150
+ - **An answer that overtakes an earlier phase's document now says so on that document
151
+ (#104).** A phase document is a point-in-time snapshot, and an owner answer recorded
152
+ three phases later can flip a design it still asserts. Measured twice on
153
+ `260830-ordering-inventory`: `03-plan/stories/S4.md` promised a `[Theory]` proving an
154
+ inert stock-effect default after F021 established Restock for 5 of 64 pairs and the
155
+ shipped test was a different one; `02-how/design.md` and its handoff said "no
156
+ order_number column is created" after F022 ordered one. Both flips were recorded — in
157
+ `questions.md`, in `facts.yml`, in `retro.md` — and neither was in the file a reader
158
+ opens. The audit's conclusion, "read `04-build/` rather than `03-plan/` for what
159
+ actually shipped", was tribal knowledge nothing on the page taught.
160
+ - **The question already names the document.** §2.7 requires `Why asked:` to end with a
161
+ `[src: …]` token proving the gap is real, and a question raised in Build about a plan
162
+ claim cites that plan claim — that is what the citation is for. So the affected set is
163
+ derived from data the grammar already makes mandatory: every `file` ref pointing at a
164
+ `.md` in an EARLIER phase of the run. No new schema, no new habit. A citation into the
165
+ question's own phase is not a supersession — that is an author reading their own
166
+ half-written page — and an optional `affects:` metadata key names documents explicitly
167
+ for when the honest citation is a source file rather than a document.
168
+ - **A marker, not a reconciliation.** One HTML comment carrying the fact id and one
169
+ blockquote line pointing at `questions.md` and `facts.yml`. Rewriting the stale
170
+ sentence would mean knowing which sentence, and guessing that is how a framework
171
+ starts inventing content. The issue asked for an honest marker; full reconciliation is
172
+ explicitly not required.
173
+ - **Three properties it had to have, each pinned by a test.** Append-only
174
+ (`appendFileSync`; the RED test asserts the phase's own bytes are still the file's
175
+ prefix). Idempotent per fact — the comment carries the id, so the `answer-capture`
176
+ hook firing twice on one edit stamps once, while a *different* fact stacks under the
177
+ first. And invisible to the checks that guard the document: the stamp is deliberately
178
+ not a list item and carries no `[src: …]`, because a bullet appended to a `handoff.md`
179
+ would land inside a §2.8 section where every item must be sourced, and `claim-sources`
180
+ would refuse the very document this makes honest. A stamped handoff validates with the
181
+ same `bulletCount` and the same verdict as before.
182
+ - Both recording paths stamp: a first answer and `tldrx answer … --supersede`. Each
183
+ stamped document appends a `doc.superseded` event (new, closed-set §2.9 type) carrying
184
+ the run-relative path, the fact, the question and whether the document was `cited` or
185
+ named in `affects`; `tldrx replay` renders it. A stamp that cannot be written never
186
+ fails the answer — the decision is recorded and the failure goes to the log as `error`.
187
+ - **The dashboard opens on a "Now" strip, and it says what it does not know (#107).** The
188
+ render half of the owner-approved redesign, on top of the model #103 shipped. The runs
189
+ view now leads with one card per **live** run — every run that is neither `done` nor
190
+ `cancelled`, so a run nobody has started and a run nothing can move are both on it, with
191
+ the ones that raise an ask sorted to the front. Each card carries the phase dots, the
192
+ status, the ask in the page's own words with the command that closes it, the spend, and
193
+ how long it has been quiet. It **replaces** the alert stack rather than sitting beside it:
194
+ it is a superset — the same `dashPending` ask, the same `alert__kind` badge, the same
195
+ sentence — over more runs.
196
+ - **A lower bound never gets a bar.** A progress bar is a claim about a denominator, and
197
+ it is honest only when the number over it is the whole of what was spent. On the run
198
+ #103 audited, 30 of 34 turns put nothing in the meter and not one declared a token, so
199
+ `$14.60 of $62.00` under a quarter-full bar was a confident wrong number drawn in the
200
+ one shape a reader cannot argue with. The bar is now reserved for `spend.basis ===
201
+ "measured"`; every other basis gets the metered figure, a **lower bound** mark carrying
202
+ `spend.reason` in its tooltip, and the count of costless turns out of the total — so the
203
+ size of the gap is a number and not an adjective. Host tokens are printed beside the
204
+ dollars and never added to them.
205
+ - **"Quiet" is 30 minutes, and it is the RENDER's threshold.** The model reports
206
+ `ageSeconds` unclamped and bakes in none, which is right — a workspace where a stage
207
+ takes forty minutes and one where it takes forty seconds cannot share a constant. So the
208
+ page picks, in one comparison, in one place, and says so on the reference page. An
209
+ `mtime` reading prints **touched** rather than "last event" (the file was written, which
210
+ is not the run moving) and a ledger dated after the read is named as two clocks
211
+ disagreeing rather than laundered into "0m ago".
212
+ - **The dots are the phases the run's own file declares.** `run new` writes the whole
213
+ workflow up front (`buildPhases`), so a `feature` run really does draw five, what →
214
+ watch, from the moment it exists — and a `docs` run draws what a `docs` run has. Nothing
215
+ is padded to five for the shape of it.
216
+ - **Three drill-ins on the run detail, and each one names what it cannot show (#107).** A
217
+ **phase timeline** — a lane per phase with its cost, each stage a `<details>` opening onto
218
+ the gate, its policy, its signer and an agent signature's evidence, with the execution-path
219
+ table moved inside a closed panel rather than drawn twice. A **story grid** — one status
220
+ cell per story, forty of them legible without scrolling, each opening onto the plan file's
221
+ fields, the attempt the ledger last recorded, the free review retries and every reopen. An
222
+ **event stream** — the three timestamped kinds the model carries (operator notes,
223
+ `budget.blocked`, `story.reopened`) in one time order, filterable by kind with the same
224
+ button vocabulary the runs list uses. Reading a note next to the refusal two minutes before
225
+ it is how a person works out why somebody rebased a branch by hand.
226
+ - The absences are printed where the numbers would be. A stage's `started_at`, its
227
+ `ended_at` and a gate's free-text `note` are in `run.yml` and on no field of the
228
+ dashboard model, so the timeline reports no duration and quotes no signature. A story's
229
+ build log and its fix list are files the Build writes and the page does not read. The
230
+ stream says out loud that it is not the ledger — `tldrx replay <run>` is. A blank cell
231
+ reads as "nothing happened", and that is the failure mode this whole redesign is about.
232
+ - **A Waves view (#107).** The plan as bars: a row per wave, a bar per story in it, fix
233
+ rounds and free review retries marked on the bar. Gantt-lite and deliberately not a Gantt —
234
+ the axis is the WAVE, because that is the only ordering the files actually assert;
235
+ `StoryModel` carries no start and no end, and an invented x-axis would read as measured. A
236
+ story the plan schedules into no wave gets its own row rather than being dropped.
237
+ - **Craft: keyboard reach, tabular money, still one file.** <kbd>j</kbd>/<kbd>k</kbd> move
238
+ between cards and rows, <kbd>enter</kbd> opens the focused one, <kbd>/</kbd> jumps to the
239
+ filters — printed under the filter row, because an undiscoverable shortcut is not a
240
+ feature, and bound only to keys no browser or screen reader already owns. Money and token
241
+ counts are tabular, so a column of cards reads as a column of numbers. The two marks that
242
+ say "this figure is not what it looks like" — `lower bound` and `quiet` — are words, not
243
+ colours, so they survive a greyscale print and a reader who cannot tell amber from citron.
244
+ Both themes still come from `prefers-color-scheme`, the export is still one self-contained
245
+ file with no network reference of any kind, and there is still no framework: vanilla, CSS
246
+ grid, and `<details>`.
247
+ - `DASHBOARD_MODEL_VERSION` does not move. Nothing here reads a file, adds a field or
248
+ changes what one means — it is the drawing half, and the model it draws is #103's
249
+ unchanged.
250
+
251
+ - **The docs site now SHOWS the dashboard instead of describing it (#106).** A new
252
+ [Live demo](https://ederwii.github.io/tldr-experts/demo) page in both languages frames a
253
+ real `tldrx dashboard --static` export — eight runs, two experts, gates, questions,
254
+ dollars — rendered at build time by `docs-site/scripts/gen-demo.ts` and served from
255
+ `/dashboard-demo/index.html`. It is the shipped `buildModel` and the shipped
256
+ `renderDashboard`, not a screenshot: change the renderer and the page on the website
257
+ changes on the next deploy, which the workflow's `paths` filter now guarantees by
258
+ redeploying on any change under `src/core/dashboard/` or the two fixtures behind it.
259
+ - **The data is synthetic, and that is enforced rather than promised.** The generator
260
+ composes its workspace from `test/fixtures/views/` (one detailed run — handoff, open
261
+ questions, budget ledger, events, two trained experts) and `test/fixtures/chain/` (seven
262
+ more, carrying every status a reader should learn to recognise and the dependency edges
263
+ between them). `assertSynthetic()` resolves every source path and refuses anything
264
+ outside `test/fixtures/` — including the framework's own checkout, which IS a tldrx
265
+ workspace and is where a careless default would land. The page is public and permanent;
266
+ a real run read here would publish a client's domain, and it would look like it worked.
267
+ - **Two things the first build got wrong, both caught by the tests that were written
268
+ first.** `model.root` is DRAWN on the page, so the export carried the build machine's
269
+ temp directory (`/var/folders/…/tldrx-demo-AUfHya/`) into a public document and changed
270
+ on every build; the demo now renders with the root it is about, and a test asserts no
271
+ path from the building machine appears anywhere on the page. And `public/demo/` collided
272
+ with `docs-site/demo.md` — `cleanUrls` builds that as `demo.html`, so a site holding both
273
+ asks GitHub Pages to guess what `/demo` means. The export moved to `/dashboard-demo/`.
274
+ - **One banner, and it is additive by construction.** A reader arriving from a search
275
+ result has none of the page's framing, so the export carries one line saying the numbers
276
+ are invented. Strip it and the bytes are identical to what the CLI writes — asserted, so
277
+ the demo cannot drift into being a mock-up of the command instead of a run of it. It
278
+ links nowhere: the export fetches nothing, and that is the property that lets it sit on a
279
+ static site at all.
280
+ - The docs workflow now installs the ROOT dependencies too. Module resolution for the
281
+ dashboard's `yaml` import walks up from `src/`, where `docs-site/node_modules` is not on
282
+ the path — without that step bun quietly auto-installs the package at build time, which
283
+ is an undeclared network fetch. Measured 2026-09-02: `import "yaml"` from `src/` fails
284
+ outright once the root `node_modules` is gone.
285
+
286
+ - **The run headline shows BOTH economies, or says why it cannot (#103).** A cold
287
+ adversarial audit of a real host-attended run (`260830-ordering-inventory`,
288
+ aparece-v2, 2026-09-02) found every ledger surface reconciling to a perfect 0.00 delta
289
+ at **$14.60** — `run.yml` `spent_usd`, the `events.jsonl` sum, the stage sums, the task
290
+ sums and the `budget.yml` phase sums — over 34 turns of which **4** carried money. The
291
+ run's own watch gate note puts the real figure at "about 81 dollars". The framework was
292
+ not mis-metering; the front page was lying by omission, and the auditor's verdict line
293
+ was "they should re-derive the cost". This is that re-derivation, as a REPORTING change
294
+ and not a metering one.
295
+ - `Run.spend` carries the metered dollars, the turns the meter could not see, and what
296
+ was declared about them. **Both spellings of "this turn cost nothing" are counted**:
297
+ the `cost_usd: null` + `metered: false` one the model already knew (14 of that run's
298
+ turns), and the flat `cost_usd: 0.00` written by an executor turn a host session drove
299
+ (16 more), which reads as a measurement of zero and is not one. `unmeteredTasks` keeps
300
+ its exact old meaning and `zeroCostTasks` is a second count beside it — a file that
301
+ says `0.00` is not re-labelled, it is counted.
302
+ - **What is not in the files is named `absent`, never guessed.** `spend.basis` is one of
303
+ `measured` / `declared` / `partial` / `absent`, and `spend.reason` says which in a
304
+ sentence carrying the CLI's own words ("the metered total is a LOWER BOUND, not a
305
+ total"), so the page and `tldrx budget show` cannot word the same fact two ways. No
306
+ price table is consulted, no token is converted to a dollar, and no estimate is
307
+ synthesised from stage prices or turn counts. The audited run is the `absent` case:
308
+ all 920,641 of its declared tokens sit on turns that ALSO carried dollars, so they
309
+ describe none of the 30 turns that carried none.
310
+ - The run detail's `spent` row now names the whole gap. It used to read "+ 14 unmetered
311
+ turns (in-session)" beside $14.60 — 14 of the 30 turns that put nothing in the meter.
312
+ - **A run says when it last moved (`lastEventAt`, `lastEventFrom`, `ageSeconds`).** The
313
+ `ts` of the last line of `events.jsonl`, falling back to the file's mtime when nothing
314
+ in it parses — and `lastEventFrom` NAMES which of the two, because an mtime is the
315
+ weaker fact: the file was touched, which is not the same as the run moving. `ageSeconds`
316
+ is a measurement with no threshold in it; nothing in the model decides what "stale"
317
+ means. It is not clamped either, so a ledger written after `now` reports a negative age
318
+ rather than a comfortable zero. The mtime is carried on `LoadedRun` by the reader that
319
+ already opens the path, so the ledger is still read exactly once per run.
320
+ - **`Run.nextAction` — who is waited on right now, where, and what closes it.** `waiting`
321
+ answers that as prose, and a card that wanted the command in a button had to regex the
322
+ sentence. This is the same answer pre-split, and **nothing in it is a second
323
+ derivation**: `kind` and `message` are `waiting`'s verbatim, `command` and
324
+ `alternatives` are the backticked spans read OUT OF that message, and `waitingOn`
325
+ applies `isMovable` — the framework's own definition of "a human could move it right
326
+ now" — in the same precedence `dashPending` already uses. `unknown` is the honest fifth
327
+ value: a `blocked` run with no sibling named has a `run.yml` recording no cursor, and
328
+ the model will not guess who fixes that.
329
+
330
+ `DASHBOARD_MODEL_VERSION` stays at **3**. Five additions, nothing removed, and no existing
331
+ field reads differently: `spentUsd` is still the same `run.yml` key holding the same
332
+ number, and has meant "METERED dollars, a lower bound when `unmeteredTasks > 0`" since v3.
333
+ A consumer that read it as a total was wrong before this wave and is wrong by exactly the
334
+ same amount after it; what changed is that the page now says how big the bound is.
335
+
336
+ - **`scripts/merge-wave.sh` now gates the documentation site too (#114).** The wave ran
337
+ typecheck, `bun test`, `build` and the runtime-seam grep, pushed, and left the site to
338
+ `.github/workflows/docs.yml` — *after* the merge. That is a build with two failure modes
339
+ the test suite cannot see: `ignoreDeadLinks: false` in `docs-site/.vitepress/config.mts`
340
+ is deliberate, so a page somebody moved breaks it, and `docs-site/package.json` runs
341
+ `gen-changelog.ts` and `gen-demo.ts` before VitePress, either of which can throw on input
342
+ no test feeds it. Both used to land on `main` GREEN and go red as a failed **deploy** —
343
+ `main` broken and the published site stale, the worst of both. `bun run docs:build` is now
344
+ the fifth gate, on the same tree as the other four, and a red one is `docs=1` in the FAIL
345
+ line and nothing pushed.
346
+ - **It blocks, and the cost is why that was affordable.** Measured 2026-09-02 on this
347
+ repo, warm: 4 s wall — VitePress 2.91 s plus a `bun install --frozen-lockfile` in
348
+ `docs-site/` that installs 126 cached packages in 165 ms — against a wave whose
349
+ `bun test` alone is ~440 s. `git status --porcelain` is empty before AND after it, so
350
+ the new gate cannot leave dirt that fails the NEXT wave's dirty-tree guard: everything
351
+ it writes (`.vitepress/dist/`, `.vitepress/cache/`, `reference/changelog.md`,
352
+ `public/`) was already ignored.
353
+
354
+ - **A surprised merge-wave assertion now prints the logs the script kept (#115, instrumentation
355
+ only — the flake is NOT fixed).** CI run 33653699970 (`b64950d`) caught one failure in 3161:
356
+ run A of the #44 concurrency test exiting 2 — *merge conflict* — in a sandbox whose only
357
+ merge adds a file nothing else touches. It could not be diagnosed, because merge-wave keeps
358
+ a red run's logs on purpose and `afterEach` deletes the sandbox they live in first. The
359
+ concurrency tests now assert through `expectExit`, which on any unexpected code raises the
360
+ exit, stdout, stderr **and every file in the `mw-<pid>` directory the script kept**,
361
+ `merge.log` first — the artifact #115 asked for. The next occurrence arrives explained.
362
+ - **What the search ruled out, so nobody repeats it.** Measured 2026-09-02 on macOS,
363
+ git 2.50.1: a truncated or empty `reference-transaction` hook does NOT fail a ref update
364
+ (both exit 0), and 800 real commits against 4000 concurrent `merge-guard.sh --install`
365
+ rewrites produced 0 failures — so the non-atomic hook install is not the cause. No test
366
+ in the suite sets `MW_LOCK_*`, so cross-file env contamination is not either. The one
367
+ mechanism that reproduces exit 2 exactly is the guard REFUSING (`fatal: ref updates
368
+ aborted by hook` → merge-wave's conflict branch), which needs the lock to carry a live
369
+ owner and a token that is not the merging run's. 40 runs of `test/merge-wave.test.ts`
370
+ four-way concurrent under 20 CPU spinners did not produce it.
371
+
372
+ - **A drift guard over the public surfaces (#121).** `test/public-surface-consistency.test.ts`
373
+ fails when the README, the docs site (both locales), `env.yml` and `package.json` stop
374
+ agreeing with each other. It reads files off disk, spawns nothing and touches no network,
375
+ and every failure names the file and line to go fix. It checks that no page states the
376
+ current version as a literal (it must be interpolated), that every release-shaped literal
377
+ left in the prose is a version we deliberately cite as history, that the README release
378
+ table leads with `package.json`'s version, that the docs config derives the version rather
379
+ than hardcoding it, that `env.yml` does not require Bun, that no quickstart tells a reader
380
+ they need Bun to run tldrx, and that no surface carries a claim we have retired.
381
+ - Red-first, against the tree as it stood: 6 of 10 assertions failed, one per real defect.
382
+ - `scripts/release-check.sh` already compared `package.json`, `plugin.json`, `CHANGELOG.md`
383
+ and the README table — but only at release time, when the drift has already shipped. This
384
+ runs on every PR through `ci.yml`.
385
+
386
+ ### Changed
387
+
388
+ - The staleness field is spelled `lastEventFrom`, not `lastEventSource`. The model is
389
+ embedded in the static page verbatim, and one of this repo's oldest guards is that the
390
+ exported page contains no `EventSource` — a static export must make no network call. A
391
+ field named `lastEventSource` puts that substring in every page and makes a JSON key
392
+ indistinguishable from the live-reload script the guard exists to catch. The guard is
393
+ right; the name was wrong.
394
+
395
+ ### Fixed
396
+
397
+ - **`validateEnv` now enforces the two `env.yml` rules §2.10 designed, and §2.10 stops
398
+ designing the third (#126).** The spec stated four validation rules as fact; the schema
399
+ enforced none. #125 removed one with its field and reworded the rest into "designed and not
400
+ yet enforced" — honest, and it settled nothing.
401
+ - **Ids are unique across `tools`**, and the message says what a duplicate costs rather
402
+ than that it is untidy: `runDoctor` iterates `tools` and probes each entry, so a repeated
403
+ id ran the same `check` twice and printed two rows for one tool. Reported at
404
+ `tools[i].id`, naming where the id was first declared. A non-string id gets its type
405
+ error and no uniqueness complaint on top of it.
406
+ - **At most 64 tools**, `MAX_ENV_TOOLS`, interpolated into the message rather than typed
407
+ into it (gh #38). Nothing bounded the file before; `env.yml` declares 7.
408
+ - **The metacharacter rule is DELETED from the spec, not implemented.** "`check` free of
409
+ `; && | > \`" was not a missing check but a disagreement about what `check` is:
410
+ `ToolChecker.check` runs `runtime.spawn("sh", ["-c", tool.check])`, so every `check` in
411
+ every manifest that ever shipped has been executed BY a shell, and §2.10's own
412
+ `[assumption]` depends on it — `check: "test -n \"$VAR\""` is nothing without a shell to
413
+ expand `$VAR`. Enforcing it would have been a behaviour change that broke the manifest's
414
+ idiom in order to defend an owner against a file they wrote, committed and reviewed like
415
+ code, running on their own machine as themselves. §2.10 now says what `check` is instead,
416
+ and `test/env-validation.test.ts` pins that a metacharacter-bearing `check` validates AND
417
+ that the runtime really shells out — measured through `ToolChecker`, not read off the
418
+ source. The `result:` / `checked_at` prose is untouched: still designed, still not built,
419
+ still says so.
420
+
421
+ - **The status line's gate counter is `machine:N`, and counts every gate a machine closed
422
+ (#127).** `runSnapshot` computed it with `gate.by === "auto"` — the selector #124 had just
423
+ removed from `tldrx status` — so an agent-signed gate was invisible on the one line an
424
+ operator actually watches. Measured on the #122 fixture after `runNext` closed an `agent`
425
+ gate: `gate.by=alanmartinez executed_by={"type":"agent","id":"alanmartinez"}` and
426
+ `autoGates=0`. A run whose only closed gate was signed by an agent showed no segment at all.
427
+ - **The label moved with the selector.** Widening the count and keeping `auto:` would have
428
+ replaced one untrue number with an untrue name: `auto` is a specific actor in this system
429
+ (`AUTO_GATE_ACTOR`), not a synonym for "the machine", and reusing it for the superset is
430
+ the exact ambiguity that hid the agent case the first time. `machine` is the word
431
+ `tldrx status` already uses one screen away — "N gate(s) closed by a machine, not by a
432
+ person" — so the glance and the report are now in one vocabulary.
433
+ - **One selector, in one place.** `closedByMachine` moved out of `status/runItems.ts` into
434
+ `core/run/gateAuthority.ts`, beside `describeGateSignature`, and both surfaces read it.
435
+ The report and the line answer the same question about the same field, and had already
436
+ drifted once: #124 fixed one copy, and #127 was the other copy, still saying `auto`.
437
+ - **The tolerant reader still claims nothing it cannot see.** It does not parse gates, its
438
+ `0` means "cannot see", and it renders as no segment — never as "no machine signed
439
+ anything". Pinned.
440
+ - Three rendered assertions changed, each because the rendering did:
441
+ `test/attended.test.ts` (`0/2 att auto:2 stale:1` → `machine:2`) and two in
442
+ `test/revoke.test.ts`. `test/statusline.test.ts` never asserted the segment.
443
+
444
+ - **`docs/spec.md` §2.10 documented an `env.yml` field that does not exist, and two values
445
+ that were stale (#125).** The example carried `version_re: "([0-9]+\\.[0-9]+\\.[0-9]+)"` on
446
+ both of its tools. There is no such field: `src/core/schemas/env.ts` requires
447
+ `["id", "required", "check", "install"]` and knows `min_version`, and extraction is
448
+ `extractVersion` in `src/core/doctor/version.ts` — "the first dotted numeric run in
449
+ stdout+stderr", one extractor for every tool, exactly as `env.yml`'s own header comment
450
+ has always said. It was designed and then dropped, and the spec kept teaching it. Nothing
451
+ reads `docs/spec.md` at runtime, so the cost was paid by a contributor who copied the
452
+ example; the two stale values were `min_version: "1.1.0"` for Bun (the manifest has said
453
+ `1.3.0` since native `Bun.YAML`) and `required: true` for Bun, which `ab90a71` (#121) had
454
+ just disproved — the published package runs on Node alone.
455
+ - Two more defects the sweep turned up in the same twenty lines, both measured: the
456
+ example's top-level `checked_at:` and its per-tool `result: {found, version, ok, checked_at}`
457
+ are written by nothing. `runDoctor` builds a `DoctorReport` and returns it, and
458
+ `ENV_MANIFEST_PATH` has exactly two readers and no writer. That design is kept and now
459
+ says so in prose ("designed, not built") instead of being shown as a file `doctor`
460
+ produces. `Required in v0: git, bun, claude` became the measured set — `node`, `git`,
461
+ `claude` required; `bun`, `python3`, `graphify`, `gh` optional.
462
+ - **The example is now asserted rather than proofread**, which is the part that stops it
463
+ happening a fourth time on the same line. Three assertions in
464
+ `test/public-surface-consistency.test.ts` (#121's drift guard, extended): it must satisfy
465
+ the same `validateEnv` `loadEnvManifest` runs on the real file; it may use no key the real
466
+ `env.yml` does not use; and it must carry the real manifest's own `required`, `check` and
467
+ `min_version` for every tool it names. `purpose` is deliberately not compared — an
468
+ illustration is allowed to abbreviate it, and to be an excerpt of two tools out of seven.
469
+ RED first, against the unedited spec: the field rule named all five invented keys and the
470
+ value rule named both stale values.
471
+ - The §2.10 Validation paragraph claimed four rules `validateEnv` does not implement. Three
472
+ are now marked as designed and not yet enforced rather than stated as fact; the fourth
473
+ (`check` free of shell metacharacters) is filed separately, because `ToolChecker` runs
474
+ `sh -c <check>` and honouring the rule would be a change of behaviour, not a check to add.
475
+
476
+ - **A revoked gate no longer records what its withdrawn signature rested on (#123).**
477
+ `revoke` reset an approved gate to `pending` and nulled `by` and `at`, but spread
478
+ `gate.evidence` straight through, so the same mapping said *nobody has signed this gate*
479
+ (`status: pending`, `by: null`) and *here is what the signature rested on*
480
+ (`evidence: {path: …, verdict: sign, sampled: 7, of: 34}`). Both cannot be true, and
481
+ `tldrx replay` — which renders the evidence block off `run.yml`, not off the events — drew
482
+ the counts of a withdrawn signature under a gate whose own closing section said
483
+ `Pending gate: alpha is waiting for tldrx approve`. #122 had already cleared `executed_by`
484
+ and `authority` on a revoke for exactly this reason and deliberately left this one call
485
+ open.
486
+ - **Everything that described the signature now leaves the mapping together**: `by`, `at`,
487
+ `executed_by`, `authority` and `evidence`. A revoked gate is back to the five keys a
488
+ pending gate has always had, byte for byte.
489
+ - **Moved, not destroyed.** `run.yml` is STATE — the resume point, read as a description of
490
+ how things are now. `events.jsonl` is HISTORY — append-only, read as a description of what
491
+ happened. The contradiction was a state contradiction, so the pointer leaves the gate; and
492
+ because an audit framework does not delete history, the withdrawn `evidence` block is
493
+ written onto the `gate.revoked` event, beside the `signed_by`/`signed_at` it already
494
+ carried and the envelope's own actor and timestamp — who took it back, and when. The
495
+ committed note never moves from `<phase>/gate-evidence/<stage>.md`, and `tldrx reject`
496
+ now says so on the way out rather than leaving an operator to guess that a cleared
497
+ pointer was not a deleted file.
498
+ - A `revoked:` trail kept ON the gate was the alternative. It was refused because it grows:
499
+ a gate may be approved, revoked, re-approved and revoked again, so the state file would
500
+ accumulate a list of withdrawn signatures — which is what the append-only log is for —
501
+ and every reader would have to learn a "withdrawn" mode for a block whose only truthful
502
+ reading in `run.yml` is "current".
503
+ - **`gate.revoked` is narrated by `tldrx replay` for the first time.** It was in the event
504
+ set and in no narrative: `bullet()` had no case for it, so it fell to `default: return
505
+ null` and a replay of a run whose approval had been taken back showed the approval and
506
+ nothing after it. Evidence moved somewhere no reader looks is evidence deleted, so that
507
+ line is part of this fix — it names both parties, the stale count, what the signature had
508
+ rested on and that the note is still on disk.
509
+ - `evidence` is written on the event **only when there was one**, so every `gate.revoked`
510
+ for a human or auto gate is shape-identical to the ones written before this. Old records
511
+ are untouched in both directions: an approved gate carrying `evidence` and none of #122's
512
+ blocks validates, loads and emits byte-for-byte, and revoking one clears its evidence by
513
+ the same single rule. Pinned in `test/revoke-evidence.test.ts`.
514
+
515
+ - **`tldrx status` now reports an AGENT-signed gate as machine-signed (#124).**
516
+ `machineSignedDetails` is the report whose whole job is naming the gates a machine closed,
517
+ so a person can take one back, and its selector was `gate.by === "auto"`. That catches every
518
+ facilitator-closed gate and no agent-closed one: an `agent` gate records the evidence note's
519
+ `by:`, which is the OPERATOR account the agent was running as — a person's name. Measured on
520
+ run `260902-discovery-pipeline-map` (the record in #122) that gate reads `by: alanmartinez`,
521
+ so the report counted it as human-signed and never offered the revoke. That is the inverse of
522
+ what the report is for, on the one closure kind where the recorded name is not the entity
523
+ that did the checking.
524
+ - The selector is now `gate.executed_by.type !== "human"`, with `by === "auto"` kept as the
525
+ **union member** rather than replaced: a gate signed before #122 has no `executed_by`, and
526
+ there the old heuristic is the only signal there is. It asks `!== "human"` rather than
527
+ naming `agent` and `auto`, because listing the machines by name is exactly how the `agent`
528
+ case went missing the first time.
529
+ - **The wording changed with it.** "N gate(s) signed `by: auto`, not by a person" was true of
530
+ the facilitator and false of an agent gate, where the record *does* carry a person's name.
531
+ The line now reads `N gate(s) closed by a machine, not by a person — <phase>/<stage> signed
532
+ by <signature>, …`, rendering each signature through `describeGateSignature` — the one
533
+ renderer `run status`, `replay` and the dashboard already share, so a fourth reading of one
534
+ fact cannot drift from the other three, and a record with no `executed_by` still prints the
535
+ bare `by` it always printed. A second line, present only when an agent gate is among them,
536
+ says that an `agent` gate is signed under the operator account the agent ran as, so the
537
+ name on it is not the entity that did the checking.
538
+ - **The executor's kind, never the policy.** A person approving an `agent`-gated stage with
539
+ no flag is a human acting directly and is still not reported. Pinned, with the legacy
540
+ fallback and the negative cases, in `test/machine-signed-gates.test.ts`.
541
+ - The statusline's counter was the same selector on a second surface, and was left alone
542
+ here because it is a different label. Filed as #127, and fixed below: it is `machine:N`
543
+ now, off this same `closedByMachine`.
544
+
545
+ - **The merge-wave suite no longer plants its #95 fixture at a machine-global path (#113).**
546
+ `b8d1fcb` gave each invocation a private `$TMPDIR`, so a run's own log root stopped being
547
+ a shared namespace — and the fixture that plants a *foreign* wave's kept log kept writing
548
+ to the constant `mw-999999` in the machine's tmpdir. Every concurrent copy of
549
+ `test/merge-wave.test.ts` planted that one directory and, in its `finally`, deleted it, so
550
+ the first process to finish removed the directory its siblings were still about to assert
551
+ on. Measured 2026-09-02 at `965eb54`, four concurrent runs of that file: **9 failures in
552
+ 12** targeted runs and **5 in 8** whole-file runs, every one of them
553
+ `expect(existsSync(foreign)).toBe(true)` receiving false. After the fix, on the same box
554
+ and the same load: **0 in 12** and **0 in 8**.
555
+ - The plant is still `mw-<pid>`, still directly in the shared tmpdir — both load-bearing,
556
+ because a regression to the pre-#95 machine-global scan has to keep finding it — but the
557
+ pid is now derived from the planting process's own, in `test/fixtures/foreignWaveLog.ts`.
558
+ It is a fixture module rather than an inline helper so the guard can be a two-PROCESS
559
+ measurement: a child imports the same function and prints the path it would choose, and
560
+ the test asserts the two differ. A test that asks one process whether its own name is
561
+ unique can only ever say yes.
562
+
563
+ - **`merge-wave.sh` no longer executes a file anything can rewrite underneath it (#117).**
564
+ Bash reads a script incrementally and seeks back into it for each next command; it does
565
+ not snapshot. `git merge --no-ff` sits about a third of the way through `merge-wave.sh`,
566
+ and everything that makes a wave honest lives after that byte — the four gates, the
567
+ `HEAD moved` assertion, the fast-forward check, the push, and the summary line. Measured
568
+ on `GNU bash 3.2.57(1)-release (arm64-apple-darwin25)`: a ~24 KB script rewritten IN PLACE
569
+ while it slept printed its head, lost its tail, and **exited 0**. Silently stopping early
570
+ and reporting success is exactly the failure this script exists to prevent, aimed at
571
+ itself. Reproduced as a wave: with the typecheck gate truncating `scripts/merge-wave.sh`,
572
+ the run merged, ran ONE of three gates, pushed nothing, printed nothing, and exited 0.
573
+ - **The script now re-execs itself from a private snapshot before it does anything.**
574
+ `exec` keeps the pid, so a caller's timeout, `kill`, `$$`, the lock's owner line and
575
+ `$LOGS` all still name the same process; the copy lives in this run's own `$TMPDIR`
576
+ under an `mw-*` name, so #95's leak assertions double as its cleanup check. The
577
+ re-entry flag is an ARGV sentinel rather than an environment variable on purpose: the
578
+ `bun test` gate runs this repo's own merge-wave suite, which spawns the script again,
579
+ and an exported flag would have let an outer wave switch off the inner ones' protection.
580
+ - **Why the live merges of #113/#114 survived, and why that is not a defence.** `git
581
+ merge` unlinks and recreates a changed file rather than truncating it — measured on git
582
+ 2.50.1, a merge that shrank a tracked file moved it from inode `192824934` to
583
+ `192824958` — so the running shell kept reading the ORIGINAL bytes off the now-unlinked
584
+ inode. That is how merge `481540d` printed `typecheck/build/seam clean` while
585
+ `git show 481540d:scripts/merge-wave.sh` already ended in `typecheck/build/docs/seam
586
+ clean`: two versions, both intact, in the same second. The merge path was safe only for
587
+ as long as git keeps replacing rather than truncating — an implementation detail of
588
+ somebody else's program, load-bearing for this script's honesty, and nothing here would
589
+ have noticed it changing.
590
+
591
+ - **An interrupted merge wave no longer leaves an ungated commit on `main` for the next
592
+ sibling to push under its own gate result (#116).** Measured live 2026-09-02 while merging
593
+ #109/#110 in the shared checkout: the caller's own 10-minute timeout sent `SIGTERM` at
594
+ minute 10 of an ~8-minute gate run. The `TERM` trap handed back the lock and the marker
595
+ and touched nothing else, so `main` sat for ~15 minutes on a merge commit that no gate
596
+ ever finished and no lock, marker or log advertised — `git status` clean, `.git/merge-wave.lock`
597
+ gone, no `merge-wave.sh` running. All three existing defences are silent on it by
598
+ construction: the lock had been released, the tree was clean, and
599
+ `git merge-base --is-ancestor origin/main HEAD` PASSES for the next wave, because
600
+ origin/main genuinely is an ancestor of the orphan. So the sibling merged on top, gated
601
+ the pair, and published both under one summary line naming only itself. Nobody lied; the
602
+ gate record was simply attached to the wrong tree — the mirror image of the #44 race, and
603
+ about the run that is *dead* rather than the one still alive.
604
+ - **The wave now owns its merge commit until the push.** `merge-wave.sh` records `main`'s
605
+ sha before `git merge --no-ff`, and `unwind` puts it back on every path that does not
606
+ publish: the `INT`/`TERM` traps, a red gate, a failed push. Deliberately narrow on three
607
+ counts — it undoes only the sha *this* run created, only while that sha is still `HEAD`
608
+ (so a third party's commit landing on top, the exit-5 case, is left for a human instead
609
+ of being discarded), and only before `PUSHED=1`. It also runs *before* the lock is
610
+ released, while this invocation still matches the lock token, so the #89 ref guard waves
611
+ it through rather than vetoing the repair.
612
+ - **A red gate rewinds too.** It used to report "main left at merge commit `<sha>`", which
613
+ is the same landmine by another route: the branch still holds every byte of the work and
614
+ the merge is one command away, while the kept log directory is the real inspection
615
+ surface. The summary line now names the sha it rewound off `main`.
616
+ - **And a wave refuses to start on a `main` that is ahead of origin/main — exit 8, naming
617
+ the commits.** `SIGKILL` and a power cut run no trap at all, so the unwind alone cannot
618
+ close the hole. `HEAD`, not `refs/heads/main`, because `HEAD` is what gets merged into
619
+ and what `HEAD:main` pushes; behind or diverged is still the fast-forward check's job at
620
+ the push. Six tests in `test/merge-wave.test.ts` pin it, including the end-to-end repro:
621
+ a wave killed mid-gate, then a sibling wave whose `origin/main` must not contain the
622
+ dead run's commit.
623
+
624
+ - **A docs-scope story bundle is no longer handed to a developer with nothing to write, no
625
+ requirements it can open, and its core acceptance criterion deleted (#111).**
626
+ All three were repaired BY HAND by the unattended driver of `260902-discovery-pipeline-map`
627
+ on 2026-09-02, and all three reproduce from one fixture: a real `run new --scope docs
628
+ --seed <root document>` where the seed is the run's source of truth, lives at the workspace
629
+ root, and the story's repo is a sibling directory.
630
+ - **`touches` was empty.** It was built from what `01-what/handoff.md` CITES with a repo
631
+ prefix, and that could only ever find files already on disk — so a run whose whole output
632
+ is a document nobody has written yet got `touches: []`, while the developer prompt tells
633
+ the sub-agent that a change outside `touches` is a plan deviation. The story was forbidden
634
+ to do the one thing it existed for. `touches` now also carries the documents the run's own
635
+ brief NAMES — `## Decisions` and `01-what/success-metrics.md` — including a path with no
636
+ file behind it yet, which `prompts.ts` has always rendered as "(does not exist yet — this
637
+ story creates it)". Two ways in and no third: the repo has the file, or the path has a
638
+ directory and the repo has that directory; a bare dotted word in prose (`Node.js`) is
639
+ refused. Every addition is written into `notes:`, and a `touches` that is still empty now
640
+ says so in `notes:` instead of leaving it to a comment on an empty list.
641
+ - **The seed documents were unreachable from the story worktree.** They live at the
642
+ workspace root, are never copied, and need not be inside the story's repo — so the
643
+ handoff's `[src: seeds/….md:5]` resolved to nothing from the worktree and the driver
644
+ rewrote every read to an absolute path. The bundle now CARRIES them: `inputs:` lists the
645
+ run's `--seed` documents (read from `run.yml`'s first stage, where `run new` declares
646
+ them) and the developer prompt inlines their content, resolved through the same
647
+ `resolveDeclared` two-base rule the What stage's own inputs use. Bounded at 64 KB per
648
+ bundle — the same budget the story's touched files spend — and anything that does not fit
649
+ is named in `notes:` rather than dropped in silence.
650
+ - **The acceptance filter deleted the story's core criterion for naming a question id.**
651
+ "All four seed questions (Q1–Q4) have a dedicated section in
652
+ `docs/discovery-pipeline-map.md`" was dropped on the `a question id` signal, leaving the
653
+ story with the "(no `## Decisions` bullet …)" placeholder as its whole Done-when list.
654
+ Naming a question is how a document ABOUT the questions is specified. A bullet that names
655
+ a PRODUCT document now survives every signal — which is what the code comment above that
656
+ list has claimed since it was written, and was not true. Two narrower repairs came with
657
+ it: `questions.md` is matched at a path boundary, so `seeds/pipeline-questions.md` — a
658
+ document the TEAM wrote — is no longer read as the What stage's own output (it had
659
+ emptied `goal:` on the same run); and every signal is now tested against the bullet's
660
+ PROSE, not its `[src: …]` token, because a citation says where a claim was checked and not
661
+ what it is about. New `withoutSrcToken` lives in `text/srcToken.ts`, the one file that may
662
+ hold the grammar (#80).
663
+
664
+ - **A closed run no longer sets up the operator's next `git pull` to be refused (#102).**
665
+ Measured on aparece-v2, run `260830-ordering-inventory`, 2026-09-02: the run closed at
666
+ `14:14:00Z`; ninety-two seconds later a commit on `epic/ordering-inventory` carried a
667
+ snapshot of the whole live `tldrx-work/<run>/` tree plus `.tldrx/memory/facts.yml`; PR #10
668
+ merged it; the operator's `git pull` was refused over 5 modified and ~40 untracked paths,
669
+ and the recovery was a rebase.
670
+ - **The cause was a gap, not a bad line of code.** tldrx has never had a code path that
671
+ commits `tldrx-work/` to an epic — story commits have excluded the state dirs since
672
+ `stateDirPrefixes` landed, and the offending commit was authored by the operator's agent
673
+ after the close. What the framework did have was a hole: in a `root_is_repo` workspace it
674
+ writes `run.yml`, `events.jsonl`, `budget.yml` and every phase document straight into the
675
+ operator's WORKING TREE and left them uncommitted for the length of the run, said nothing
676
+ about them at close, and offered no verb for "the run is over, commit its docs". So an
677
+ agent invented one, and half the time invented it onto the epic.
678
+ - **The close now commits that state itself.** `closeRun` — one home for the policy, the
679
+ same three callers `closeRunWorktrees` had (`tldrx next`, `tldrx approve`, `tldrx run
680
+ cancel`) — commits `tldrx-work/<run>/` and `.tldrx/memory/` in the workspace checkout, on
681
+ the branch that checkout is on, and prints one line saying where they went. New
682
+ `commitPathsOnly` is the inverse of `commitAll`: `git commit -- <pathspec>`, so a
683
+ `README.md` the operator had STAGED is still staged and still uncommitted afterwards.
684
+ Two paths refuse rather than guess — a checkout sitting on the run's own epic branch
685
+ (`on-epic`) and a detached HEAD — and both report instead of failing the close. It never
686
+ pushes; spec §5 is unchanged.
687
+ - **`tldrx ship` refuses an epic that carries the framework's own state**, names the paths,
688
+ and prints the two-command repair (`git checkout <base> -- tldrx-work .tldrx` then a
689
+ commit — a forward commit, never a rebase). This is the last point tldrx holds the wheel
690
+ before a PR, and the diff is three-dot so a trunk that gained run state after the epic was
691
+ cut does not read as the epic carrying it. A `git diff` that fails answers "nothing here":
692
+ a probe that could not tell must never turn into a refusal.
693
+ - **Every `*.bak` tldrx writes is gitignored, at any depth.** `tldrx-work/*/*.bak` reached
694
+ one level and missed `tldrx-work/<run>/04-build/preflight.yml.bak`, which
695
+ `git check-ignore` matched against the block's own `!tldrx-work/**` re-include — measured,
696
+ and it was swept into the aparece-v2 rescue commit. Now `tldrx-work/**/*.bak` and
697
+ `.tldrx/**/*.bak`. Existing workspaces pick the block up on the next `tldrx init` (it is a
698
+ marked block, so re-running it is idempotent); until then the close excludes `*.bak` from
699
+ its own pathspec, so the one write path that could have committed them does not.
700
+ - **A 1M-context model is no longer sized at 200k because of how its name was spelled
701
+ (#112).** Measured at `d1d9c3f`: `priceFor("claude-sonnet-4-5-20250929[1m]")` returned the
702
+ `sonnet` row and `contextTokensFor` therefore answered **200,000, not 1,000,000**. The
703
+ cause was that `[1m]` was carried inside a row's id, so `name.includes("sonnet[1m]")`
704
+ could only ever match the bare alias — and the suite could not see it, because the one pin
705
+ that existed (`priceFor("opus[1m]")`) used exactly that convenient spelling while the
706
+ repo's own fixtures use the dated form (`claude-fable-5[1m]`). It fed `run estimate` and
707
+ the context ledger, which decides when a stage is near its window.
708
+ - **`[1m]` is now matched as a MARKER on a name, separately from the family**, so
709
+ `sonnet[1m]` and `claude-sonnet-4-5-20250929[1m]` land on the same row. A `[1m]` row is
710
+ offered only to a name that carries the marker, so a dated spelling WITHOUT it is not
711
+ promoted: `claude-opus-4-5-20251101` still resolves to `opus`/200k.
712
+ - **A family the table prices answers for its own windows.** haiku has no `[1m]` row
713
+ because there is no 1M haiku, and its 200k is the one MEASURED number in the file — a
714
+ marker does not overrule it, so `claude-haiku-4-5-20251001[1m]` stays 200k.
715
+ - **A family the table does NOT price gets the window its name declares.**
716
+ `contextTokensFor("claude-fable-5[1m]")` is 1,000,000 rather than a silent 200k default.
717
+ No fable price row was invented: `priceFor` still returns null for it, so nothing quotes
718
+ a price for a model this table cannot price — only the sizing changed.
719
+ - No USD figure moved. The `[1m]` rows already priced identically to their 200k siblings;
720
+ this was never a billing bug, and `tldrx cost` reads `total_cost_usd` off the CLI.
721
+ - **A stage with nothing to ask can close its own auto gate again (#109).** `questionsCondition`
722
+ read zero parsed question blocks as one thing when it is really three. *Unreadable* — the
723
+ 2026-08-29 failure, where a stage wrote `### Q1 — …` from the old template and four questions
724
+ were swallowed — is still refused, by id. *Missing* — a `questions.md` the stage declared as an
725
+ output and never wrote — is still refused, and now says so in its own words
726
+ (`questions.md is a declared output of this stage and was never written — an absent file is not
727
+ an answer`) instead of sending the reader to a template for a file that does not exist.
728
+ *Present, readable, and raising nothing* — the GOOD case — now SATISFIES the condition. Measured
729
+ live 2026-09-02 on run `260902-discovery-pipeline-map`: every clean stage was paying for the
730
+ 2026-08-29 bug, because the state an auto gate exists to close over was indistinguishable from
731
+ the state it exists to refuse.
732
+ - **`absent:` has ONE semantic, and both checkers share it (#110, absorbing #105).** Two live
733
+ failures in the same week, in opposite directions, out of the same resolution: `claim-sources`
734
+ PASSED `- none [src: absent:04-build/log]` over a directory holding seven files, while the auto
735
+ gate REFUSED `absent:.tldrx/memory/facts.yml` — "I searched, there is no recorded fact", which is
736
+ the spec's own spelling of an empty section. Both were `unverified`, which one checker read as
737
+ "fine" and the other as "stop".
738
+ - **`absent:<path>` now resolves against the same bases a `file` src does** (workspace root, run
739
+ directory, named repo, epic worktrees), and accepts `repo:path`. That is the mechanism behind
740
+ #105: it used to try the workspace root and nothing else, so a run-relative
741
+ `absent:04-build/log` never even saw the directory it named and reported an absence it had
742
+ never looked for.
743
+ - **A fourth outcome, `noted`.** No such path, an empty file, an empty directory ⇒ `ok`, the
744
+ absence is literal. The path exists with content and no needle ⇒ `noted`: legal, never fatal,
745
+ never blocking — and never silent. It is counted and named BY PATH in the `claim-sources`
746
+ detail (`unchecked absence: 2 (04-build/log, .tldrx/memory/facts.yml)`) and therefore in the
747
+ auto-gate note, so the two can no longer disagree about the same file.
748
+ - **`absent:<path>#<needle>` makes an absence checkable.** Say what you searched for and the
749
+ checker searches for it: not found is a *verified* `ok`, found is a REFUSAL that names the line
750
+ (`` `30 days` IS at docs/retention.md:3 — that is a presence, not an absence ``). It is the
751
+ upgrade path out of `noted`, and the first form in which an absence over a file with content is
752
+ ever actually checked.
753
+
754
+ - **The docs site advertised a version we had already shipped past (#121).** The hero said
755
+ 0.4.0 in four places — `index.md`, `guides/faq.md` and both Spanish mirrors — while npm
756
+ served 0.5.0. Hand-bumping it would have bought one release; the number is now read at
757
+ build time instead, by `docs-site/version.ts`, from `package.json` (the field npm
758
+ publishes) and the README release table (the row `release-check.sh` already refuses to
759
+ release without). `config.mts` hands both to the pages through `themeConfig`, on the root
760
+ and on each locale, and the pages interpolate. `version.ts` throws rather than guesses: a
761
+ docs build that cannot tell which version it is describing fails, instead of quietly
762
+ publishing `undefined`. The "0.4.0 was the first beta" sentences stay — that is history,
763
+ and it is still true — but they now read as history.
764
+
765
+ - **Three surfaces disagreed about whether Bun is needed to run tldrx (#121).** `env.yml`
766
+ declared `bun` `required: true`, and `DoctorReport.healthy` is "no required tool is
767
+ missing", so `tldrx doctor` exited 1 on machines where tldrx was installed and working.
768
+ The docs site's quickstart said you need "Node 20 **and** Bun 1.3"; the README and
769
+ `docs/guide/01-quick-start.md` said an installed tldrx needs only Node. The README was
770
+ right, and the code says so: `node dist/tldrx.js --version` prints the version and exits 0
771
+ with no Bun involved, because `src/core/runtime/index.ts` picks its implementation off
772
+ `typeof Bun` at import time and `nodeRuntime.ts` is complete — `bun build --target=node`
773
+ inlines the `yaml` package, so a published install resolves zero runtime dependencies.
774
+ - `bun` is now `required: false` in `env.yml`, still declared and still carrying its 1.3.0
775
+ floor, with the measurement recorded beside the flag. It remains genuinely required to
776
+ build the bundle, to run the test suite, and to run the hooks straight from a clone
777
+ (`plugin/hooks/hooks.json` spawns `bun`); an `install --claude` wiring goes through
778
+ `tldrx hook <name>`, which is the Node bundle.
779
+ - Every surface now separates *running the published package* from *building and
780
+ contributing*, in the same words: both quickstarts, the README, `01-quick-start.md` and
781
+ `09-troubleshooting.md`.
782
+
783
+ - **The landing page claimed an absolute the implementation cannot guarantee (#121).** It
784
+ told readers that because the files are the state, nothing could get out of step with
785
+ anything — in the same week that #116 and #117 shipped precisely because state can. It now
786
+ says what is defensible and still worth saying: the canonical state is on disk,
787
+ inspectable, diffable, committable, recoverable. Mirrored in Spanish.
788
+
789
+ - **Provider wording tightened to what we can show (#121).** "Tool-agnostic" was an
790
+ unqualified claim about software with one working runner. The README and `docs/concept.md`
791
+ now say the workflow and the persisted state format are provider-independent, and that the
792
+ automated runner currently supports Claude Code. The docs site made no stronger claim; it
793
+ was swept and needed no change.
794
+
795
+ ## 0.5.0 — 2026-09-02
796
+
797
+ ### Added
798
+
799
+ - **The dashboard answers the five questions #85 left open (#93).** Each needed a decision
800
+ rather than a patch, and each was taken the CONSERVATIVE way: render what the files
801
+ already say, invent no interaction the page does not already have, and let nothing on a
802
+ read-only page run anything. Every choice below is a **maintainer call, subject to owner
803
+ review** — they are listed on the issue.
804
+ - **The Watchers tab draws the cards.** `05-watch/watchers/*.md` is read into the model as
805
+ the seven fields `Watcher` really declares — `id`, `epic`, `title`, `stories`, `repos`,
806
+ `status`, plus the optional `owner` (#70) — and the tab prints them instead of printing
807
+ the shape it wished it had. A `draft` card also carries **why**: the `absent:` sources it
808
+ cites under `## Signal`, which is the card's own rule for not being `verified`. The
809
+ reading is deliberately the SMALL one — the model resolves no `[src: …]`, calls no
810
+ `parseWatcherCard` and computes no `CardChecklist`, because that re-checks every citation
811
+ against today's working tree and would make a read-only dashboard the only screen in the
812
+ product that runs something. `tldrx watch check` stays the thing that checks, and the
813
+ page says so. A `verified` stamp over an `absent:` Signal is shown as what it is rather
814
+ than silently corrected: `watch check` re-stamps cards, and a viewer that disagreed with
815
+ the file would be a third opinion.
816
+ - **A `draft` card raises no attention card.** The page's rule — an alert means a run is
817
+ waiting on a PERSON right now, derived once in `waiting.ts` — is unchanged. An
818
+ uninstrumented signal is a fact about coverage that stays true until somebody
819
+ instruments it, and it belongs in a panel the way `budget.blocked` does.
820
+ - **Preflight refusals leave their rows on the page.** `04-build/preflight.yml` is read the
821
+ same way `budget.yml` is: read-only, additive, and through the reader that never throws.
822
+ A new **Base gates** section names each of the workspace's own gate commands, the repo
823
+ and base it ran on, its exit code and its `ok`/`failed`/`unmeasured` status — so a Build
824
+ that refused to start is no longer a stage that went backwards for no visible reason.
825
+ The alternative on the issue was to emit an EVENT instead; that is the bigger, better
826
+ change and it is not this one, because the owner's #85 decision covered the two files
827
+ that already existed and an event is a new write on a refusal path. Reading the file it
828
+ already writes is the same doctrine one step further. Red rows are drawn as rows, not as
829
+ alerts, for the same reason `budget.blocked` is.
830
+ - **`keep_worktrees` is one line on the run detail**, and only when it is set (#16). The
831
+ key is written only when true, so drawing `false` would put a row on every run in the
832
+ workspace saying what all of them do. `RunDocument` projects it tolerantly: anything that
833
+ is not the boolean `true` is not a promise to keep the worktrees.
834
+ - **A cancelled run says who closed it, when, and why.** All three facts already travelled
835
+ on `waiting.message` (#86); the run detail printed the status chip and dropped them. It
836
+ is one `kv` row carrying that sentence — not a second derivation, so the page cannot word
837
+ it differently from `tldrx run status`.
838
+ - **An annotated run wears a marker in the runs list** — one ✎ glyph with the note count in
839
+ its `title`, nothing more. #85 §1 asked for it and the wave declined to invent it. This
840
+ is the smallest thing that is true: no column, no badge count, no new sort key, and the
841
+ notes themselves stay on the run detail where they were. Explicitly **provisional** — a
842
+ count in a row is a design decision, and the first person with a real opinion about that
843
+ list should replace it.
844
+ - **`DASHBOARD_MODEL_VERSION` stays at 3.** `watch`, `preflight` and `keepWorktrees` are
845
+ three additions and nothing was removed; no existing field reads differently than it did
846
+ at v3. The argument for bumping is that `docs/dashboard-model.md` promised, under *What
847
+ is NOT in it*, that two named files were unread, and that promise is now void — but a
848
+ documented absence is not a field, and the number is for fields.
849
+
850
+ - **The dashboard reads `budget.yml` and `events.jsonl` (#85).** The audit that filed the issue
851
+ found five gaps with one root: `buildModel()` read `run.yml`, the phase artefacts, the Plan
852
+ artefacts and the expert files, and nothing else — so five facts a reader went looking for were
853
+ nowhere on the page, each because it lives only in one of those two files. The owner's decision
854
+ (2026-09-02) was that the model may read both, read-only and additive. All five are now drawn.
855
+ - **Operator notes** (`tldrx note`, #46) get their own section on the run detail, with the actor,
856
+ the stage or phase, and the time. **All of them**, not the last three: `tldrx run status` caps
857
+ at three because a terminal has a bottom, and a run detail page does not.
858
+ - **Free review retries** (#78/#79) and the **attempt** each story is on. Both are event-only —
859
+ the story file carries `status` and `evidence` and no counters — so a story that burned both
860
+ attempts and was granted two free re-prompts read on disk exactly like one nobody had touched.
861
+ The plan table gains an `attempts` column (`1 of 2`, against the model's new `maxAttempts`), and
862
+ a **Reopens & retries** section shows the `story.reopened` arcs with their `fix`/`attempts`
863
+ reason and the operator's note. A reopen written before the `reason` key existed reads as
864
+ `attempts`, which is the only kind that existed, rather than as a blank.
865
+ - **`budget.blocked`** occurrences are listed with the phase, both economies' numbers, and — for a
866
+ dollar refusal — the exact `tldrx budget raise` command, short-by rounding included, pinned by a
867
+ test against `raiseCommand` because the renderer is serialised into the page and cannot import
868
+ it. Deliberately **not** an attention card: the page's rule is that an alert means a run is
869
+ waiting on a person NOW, and a refusal in the log is not evidence of that.
870
+ - **The `$0.00 of $25.00` progress bar on a host run is fixed properly.** A run whose `budget.yml`
871
+ says `economy: host-tokens` is not priced in dollars at all, so `ceiling_usd` governs nothing
872
+ and the bar stated a fraction of a denominator that does not apply. Such a run now reads in
873
+ TOKENS on both screens — the runs list and the run detail share one `dashSpendText`, because
874
+ suppressing the bar alone would leave the words `$0.00 of $25.00` making the claim the bar
875
+ was — metered against `ceiling_host_tokens`, the ceiling those tokens really are judged
876
+ against, which exists in no other file. A **Budget** panel carries the per-phase ceilings, `on_exceed`,
877
+ `warn_at_pct`, `on_host_tokens_exceed` and the per-phase economy. An unset phase economy is
878
+ reported as *inherits*, never as a choice somebody made.
879
+ - **`DASHBOARD_MODEL_VERSION` stays at 3**, and the issue asked. Eight fields were added and none
880
+ removed. `spentUsd` was the one with a case to answer — a consumer reading it alone is
881
+ demonstrably wrong about a host-attended run now that the ceiling can sit beside it — but it is
882
+ computed from the same `run.yml` key, holds the same number, and has meant "METERED dollars, a
883
+ lower bound when `unmeteredTasks > 0`" since v3 put `unmeteredTasks` and `hostTokens` next to it.
884
+ A field that gained neighbours did not change meaning.
885
+ - **Neither file is opened by the model**, and the ledger is walked **once per run**. `loadRunResult`
886
+ already parsed both for every run and this file had been discarding them, so the page costs the
887
+ reads it always did; the per-story facts come out of one pass rather than from
888
+ `readReviewLedger`, which re-reads the whole ledger per story and would have made a forty-story
889
+ plan forty passes over a file already in memory.
890
+ - **Absence and damage stay graceful.** No `budget.yml` and no `events.jsonl` renders exactly as
891
+ before, with none of the new sections. An unparseable `budget.yml` costs the panel and nothing
892
+ else. A torn ledger line costs that line and **says so** on the page, because "no operator notes"
893
+ over a damaged ledger is the same lie by omission an unlisted corrupt `run.yml` was.
894
+ - Fixed on the way: an event with **no `payload` key at all** parses fine through the tolerant
895
+ `EventLog.readAll`, and reading `payload.story` off it threw a `TypeError` out of `buildModel`
896
+ and killed the live server for the whole workspace. `TldrxEvent.payload` is typed non-optional;
897
+ the type is a claim about `validateEvent`'s output, not about what is in the file.
898
+
899
+ - **The `tldrx drive` mandate carries its own preflight (#84).** Launching a cold unattended
900
+ session took SIX hand-run commands before the mandate could be pasted at all — `tldrx run
901
+ attend host`, then `tldrx run gates set` five times. Every one of them is a precondition of
902
+ the discipline the mandate exists to transfer, so an owner typing them by hand was doing the
903
+ driver's job for it, and a mandate that assumes its own preconditions only works where
904
+ somebody has already been careful. Both modes now open with a **`## Before anything: the
905
+ preflight`** section, ahead of the roles, that establishes attendedness (`tldrx run status
906
+ <run>`), the gate policy (`--json`, stage by stage) and `budget.yml` — and makes the driver
907
+ **state the ceiling it will honour** before the first turn. Any one it cannot establish is a
908
+ **refusal to start** that names the command that failed, because a driver that starts anyway
909
+ has spent money on a run whose gates it may not close.
910
+ - The two modes differ here exactly as they differ at the gate. `--unattended` may SET what
911
+ it finds wrong: `tldrx run attend host <run>`, and `tldrx run gates set <stage>:agent
912
+ --note "…"` for a stage the owner delegated, over a note **quoting the owner's own
913
+ delegation from the launch message** — so the policy change is signed by the owner's words
914
+ rather than the driver's judgement. `--attended` gets the mirror and may not: gates stay
915
+ human there, so it checks attendedness and the budget, reports a stage that is not `human`
916
+ where it was expected, and moves nothing. A test asserts the attended text contains no
917
+ `gates set <stage>:agent` at all.
918
+ - The `<run>` substitution (#75) covers the new lines like every other, `<stage>` untouched:
919
+ the pinned occurrence count moves from 5/7 to **7 attended / 10 unattended**.
920
+ - Both mandates stay inside `MANDATE_MAX_LINES` (119 unattended, 116 attended), paid for out
921
+ of the header, the driving block and the unattended gate. The sections that make the
922
+ document worth pasting — the three-role spine, evidence discipline, parking, review
923
+ calibration by stakes and budget honesty — are untouched.
924
+
925
+ - **The retro aggregate has consumers: `--json`, the reviewer prompt, and a workspace taxonomy
926
+ (#74).** `tldrx retro --all` (#64) produced exactly the dataset that issue asked for — finding
927
+ class × count × runs × one cited example — and nothing read it. The reader existed; the loop it
928
+ was meant to close was open at the far end. Three seams close it.
929
+ - **`tldrx retro --all --json`** — a stable machine shape: `{version, root, runs, contributed,
930
+ deduped, classes, trends[{cls, count, runs, example{run, kind, text, src}}], findings[]}`. A
931
+ deliberate projection rather than a dump of the internal type, with its key sets asserted
932
+ literally by a test, so adding a field is a visible act and renaming one bumps `version`.
933
+ `--json` belongs to `--all` alone: closing one run WRITES a file and has nothing to parse, so
934
+ `retro --json` on its own is a refusal rather than a stringified sentence.
935
+ - **The adversarial reviewer is fed the workspace's top three classes** before it reads the
936
+ diff, so a review starts from what this team keeps getting wrong instead of rediscovering
937
+ `test-cannot-fail` on its own, run after run. Computed by the same `mineAll` over the same
938
+ workspace, so what the reviewer is told is exactly the top of what `tldrx retro --all` prints.
939
+ Additive and absent-safe in every direction — no runs, no findings, nothing but `other`, or a
940
+ broken taxonomy file, and there is no section at all, not a heading and not a blank line.
941
+ `other` is never offered: it names no defect to look for. Mined once per `tldrx next`
942
+ invocation, so a wave of six stories pays for it once and every reviewer in that invocation
943
+ gets the same prior. The section says out loud that it is a PRIOR and not a checklist — a
944
+ reviewer handed three defect classes and no framing finds three defect classes.
945
+ - **`.tldrx/memory/finding-classes.yml`** adds classes for defects the seven built-in ones do
946
+ not name (`version: 1`, `classes:` of `{name, rules}`, 1–16 each, rules compiled
947
+ case-insensitively). Extensions are tried AFTER every built-in rule and before `other`, so a
948
+ workspace class can only ever claim a finding the built-ins left unclassified — which is what
949
+ keeps an unbounded taxonomy testable, and leaves every shipped fixture immune to whatever a
950
+ workspace writes. A file that will not load is a REFUSAL naming the file, the class and the
951
+ rule — including a rule such as `.*` that matches every text and would swallow the taxonomy —
952
+ never a silent fallback, because a rule its author believes is running and is not would make
953
+ every count a lie. The Build reviewer never fails on it: a refusal costs the prior and prints
954
+ one line, so no story loses an attempt to a YAML typo.
955
+
956
+ - **`CONTRIBUTING.md`, and a section an outside contributor can build a model provider from
957
+ (#27 companion).** #27 (a generic model-provider layer) was closed as parked, not rejected:
958
+ `TLDRX_CLAUDE_BIN` covers today's needs and a provider layer with no second provider is
959
+ speculation. So the seam is written down instead. The new file covers the contribution loop,
960
+ the four gates and what CI actually runs, the red-first rules (including "a test that cannot
961
+ fail is worse than no test", with the shipped example of one that could), and a
962
+ **Contributing a model-provider config** section: what `TLDRX_CLAUDE_BIN` does and does not
963
+ buy, `buildClaudeArgs`' exact command surface, the `stream-json` transcript contract
964
+ `AgentStream` parses and the two behaviours an adapter most easily breaks (the read cap and
965
+ the cost ledger), the result-envelope requirement with its fail-closed rule, and the five
966
+ test files a PR would have to touch. Linked from the README.
967
+
968
+ - **`tldrx questions cards [<run>]` — a parked question, as something to decide (#59).** Measured
969
+ on run `260830-ordering-inventory`, 2026-09-01: the host parked four product questions with
970
+ notes and reported them in its tl;dr. The owner's live words were *"cuales preguntas? no las
971
+ veo? por que no me guió por las preguntas Claude?"* — they were on disk, they were in
972
+ `tldrx questions <run>`, and the count was in the summary. None of that PRESENTED them. Counted
973
+ is not asked, and the gap was never the data; it was the arc from parked to answerable.
974
+ - **Three slots, each a refusal.** Two lines of context (which run and file the question is
975
+ parked in, who asked it, when, in what area) so a card pasted into chat stands up away from
976
+ the terminal. The question's OWN `Why asked:` note verbatim, `[src: …]` included — the slot
977
+ for what the binding docs already decide, quoted rather than summarised; a note that cites
978
+ nothing is FLAGGED as somebody's recollection, and a question parked with no note says so.
979
+ The file's lettered options verbatim, or a loud `NEEDS OPTIONS` marker when it carries none,
980
+ because manufacturing A/B/C would be answering the question in the act of asking it.
981
+ - **A reader, and only a reader** (owner decision 2026-09-01: no interactive loop in v1). It
982
+ opens no run, spawns nothing, records no fact, and a test asserts `questions.md` is
983
+ byte-identical across a render. Answers still flow through `tldrx answer`, and every card
984
+ prints the exact line to type. It does not extend the §2.7 grammar: every field on a card is
985
+ one the existing parser already produces.
986
+ - **No open question is a sentence and an exit 0** — and two silences are told apart, because
987
+ "this run never parked anything" and "everything here is answered" send a reader to different
988
+ places.
989
+
990
+ - **`tldrx watch arm --run <id>` — the merge detector that fires the post-merge checklist (#69).**
991
+ `watch check` (#65) answers *"what do I check now?"*. Nothing answered *"the PR just merged, go
992
+ and check it"* — the half that happens without a human remembering, which matters because the
993
+ failure #65 was filed about IS a memory failure: the owner's own CD gap, a merged branch read as
994
+ a deployed one, cost 19 destroyed records. v1 would not have caught it either, because nobody
995
+ ran it.
996
+ - **A bounded FOREGROUND poller, not a daemon** (owner decision 2026-09-01: no GitHub Actions in
997
+ v1). It reads the branch Build cut from `run.yml` — through the same `pickBranch`/`findRepos`
998
+ `tldrx ship` uses, now exported, so the two verbs cannot disagree about which branch a run
999
+ shipped — asks `gh pr view <branch> --json state,mergedAt` per repo, and prints the same
1000
+ checklist the moment every PR for that branch has merged. **Three independent bounds**: a hard
1001
+ deadline (`--timeout`, default 3600s, max 86400), a floor on the interval (`--interval`,
1002
+ default 60s, under 10 REFUSED rather than quietly raised), and a poll cap that holds even if
1003
+ the clock does not move.
1004
+ - **Every refusal is a sentence, and they are different sentences.** No epic branch ⇒ `ship`
1005
+ cannot have run. No PR for the branch ⇒ either `ship` has not run or the branch was never
1006
+ pushed, with both commands printed. A PR `CLOSED` without merging ⇒ stop now, because waiting
1007
+ for it could only ever time out. A window that expires ⇒ exit `4` and the command that
1008
+ re-arms it.
1009
+ - **It never pushes, opens or merges anything**, and `--execute` is deliberately not offered: an
1010
+ hour-old poller must not start running the workspace's build commands the instant a merge
1011
+ lands. Re-running a recorded command stays an explicit, typed decision.
1012
+ - **No test in this suite runs the real `gh` or touches a network.** The unit cases drive a
1013
+ recording fake transport; the one end-to-end case puts a stub `gh` first on `PATH`. The clock
1014
+ and the sleep are injected, so a test covering a one-hour timeout finishes in milliseconds.
1015
+
1016
+ - **`tldrx plan schema` — the story/epic/waves contract, printed for a human (#71).** #48 deleted
1017
+ `templates/story.md` and `templates/epic.md`, rightly: nothing read them, so nothing kept them
1018
+ honest. But they were answering a real question — what shape does a story file take? — and after
1019
+ #48 the only reader of the generated contract was `checkContracts.ts`, which splices it into the
1020
+ Plan stage's prompt. The shape existed for the agent and not for the person writing a story by
1021
+ hand, reviewing one an agent wrote, or debugging a `plan` check refusal.
1022
+ - **The same bytes the agent gets.** `plan schema` renders `renderPlanSchemaContract()` verbatim,
1023
+ generated from the validators the check runs, so it cannot become the second source of truth
1024
+ the templates were. `--story`, `--epic` and `--waves` print one example on its own; at most one
1025
+ of them, because two would make the answer ambiguous. A test copies the printed story example
1026
+ straight into `validateStoryFile` and asserts it comes back clean.
1027
+ - **The one verb in `plan` that resolves nothing.** No workspace, no run, no disk, no spend — the
1028
+ question comes before any of that exists, and is often asked from outside a workspace entirely.
1029
+
1030
+
1031
+ - **`tldrx drive [--attended|--unattended]` — the host/driver mandate, shipped (#63).** Every elite
1032
+ run of 2026-08-31/09-01 was driven by a session carrying a hand-written playbook, and that playbook
1033
+ was the framework's real quality floor. It lived in the owner's chat pastes, so a third party
1034
+ inherited the CLI and rediscovered the discipline — or did not. It is now plain text the package
1035
+ prints, versioned with the package, for a human to paste into the driving session or read before
1036
+ they start.
1037
+ - **What it carries**: the three-role protocol (developer sub-agent → a FRESH adversarial reviewer,
1038
+ never the author → the host verifying BOTH in the code, not in their reports); evidence
1039
+ discipline (measured / inferred / assumed labelled in the same sentence as the claim, exit codes
1040
+ never read through a pipe, verification from the source, remote shas via `git ls-remote`);
1041
+ parking product questions with what the docs already decide, because an open question is never a
1042
+ licence to ship an unguarded write; review calibration by stakes, so a security-bearing story
1043
+ gets the strongest reviewer; and budget honesty — declare a turn once, state a floor when the
1044
+ records are incomplete.
1045
+ - **Two modes, one spine.** The disciplines are identical — they are about how a claim is made,
1046
+ not about who is watching. What differs is the GATE (`--unattended` signs over a validated
1047
+ evidence note with `tldrx approve --as-agent`; `--attended` never signs and hands the decision
1048
+ over) and DRIVING (who spawns). Each mode is bounded at 120 lines, asserted by a test: a mandate
1049
+ long enough to skim is one nobody follows.
1050
+ - **A mode is required and never guessed** (exit `1`), the same refusal `tldrx run attend` makes.
1051
+ Handing an attended session the unattended text tells it to sign gates that were never its to
1052
+ sign. It needs no workspace, opens no run, spawns nothing and writes nothing.
1053
+
1054
+ - **`tldrx retro --all` — cross-run mining of what keeps catching you (#64).** Each run left gold
1055
+ nobody aggregated: a reviewer's verdict and findings in `04-build/log/<story>.md`, a fix list with
1056
+ a disposition per finding, `retro.md`'s `## Build feedback`, and the reason a person typed when
1057
+ they reopened a story. Across the first six real runs the same finding CLASSES kept coming back,
1058
+ and nothing read more than one run at a time.
1059
+ - **A trends table**: finding class × count × how many runs it appeared in × one example with the
1060
+ `[src: …]` that lets a reader go and check it.
1061
+ - **Seven classes, in a documented precedence**: `test-cannot-fail`, `missing-negative-control`,
1062
+ `unreachable-structure`, `stale-comment`, `authorization-not-widened`, `schema-drift`, `other`.
1063
+ Classification is ordered keyword rules over the finding text — no model, no scoring, no
1064
+ threshold — so the same tree always produces the same table and a rule that misfires can be
1065
+ pointed at. The rules are tested against fixtures in the shape the real artefacts carry.
1066
+ - **Zero new state.** It writes nothing anywhere: no `retro.md`, no `practices.md`, no cache.
1067
+ A test asserts the workspace is byte-identical across the call.
1068
+ - **Absence is never an error.** A run with no Build phase, no retro, no events log or an
1069
+ unreadable one contributes what it has and is still counted; an empty workspace is an empty
1070
+ answer at exit `0`. A repeat of one finding WITHIN a run is collapsed (`retro.md` quotes the fix
1071
+ list verbatim); the same finding in two runs is two occurrences, which is the point. A `refuted`
1072
+ fix-list finding is read and dropped — ranking a class by disproven findings would make the
1073
+ table a report on the reviewer.
1074
+ - `--all` is refused (exit `1`) alongside a `<run-id>` or `--apply`, before a file is opened: each
1075
+ asks for the opposite of what `--all` does.
1076
+ - **`tldrx watch check` is the post-merge checklist (#65, owner decision 2026-09-01: manual command
1077
+ first, `gh` detector later).** `tldrx ship` opens the PR carrying the handoff and the watcher card
1078
+ lists the signals that would prove the feature works — and nothing joined the two, so
1079
+ "merged ≠ deployed ≠ verified" stayed a thing a person had to remember. Remembered wrongly once, it
1080
+ destroyed 19 records. `watch check` now reads the run's cards under `05-watch/watchers/` and prints
1081
+ each card's `## Signal` items as a numbered checklist with `## Where`, the baseline, what broken
1082
+ looks like, and the Query block, so there is one screen to work through after a merge.
1083
+ - **A feature id is now OPTIONAL.** `tldrx watch check` checks every card in the run — the shape a
1084
+ CI job wants; `tldrx watch check <feature>` scopes it to one and still prints the citation
1085
+ verdict `check` always printed. The citation re-check is unchanged and still exits 1 on a dead
1086
+ `[src: …]`.
1087
+ - **Owners are DERIVED, never invented.** The watcher schema has no `owner:` key and adding one
1088
+ would have been a key nothing writes (the #48 defect again). The owner of a signal here is the
1089
+ repo its own citation names — `[src: api:src/Leaderboard.cs:64]` says `api` — and an item that
1090
+ cites no repo says so rather than borrowing the card's.
1091
+ - **Runnable means the card AND the workspace say so.** Only a `$ <cmd> → exit <n>` source whose
1092
+ command `.tldrx/workspace.yml` declares, in a repo the item or the card names unambiguously, is
1093
+ offered. `--execute` (off by default) re-runs exactly those, through `runDeclaredCommand` — the
1094
+ same allowlist, argv-never-a-shell and timeout the stage `cmd` check uses, extracted rather than
1095
+ copied — and reports the exit each gets NOW against the exit the card recorded. That is the one
1096
+ thing in the framework that catches a card whose `$ … → exit 0` has quietly become an exit 1:
1097
+ `resolveSrc` checks a `cmd` source's MEMBERSHIP and takes the exit code on the agent's word.
1098
+ - **A `## Query` block is never runnable.** It is KQL or SQL for the console named under
1099
+ `## Where`, which tldrx has neither credentials nor a client for, so it is reproduced with its
1100
+ fence language and marked print-only.
1101
+ - **Three refusals, told apart because they need different actions.** No `05-watch/` at all ("its
1102
+ Watch stage never ran"), a Watch stage that wrote no card ("no story reached `done`"), and a
1103
+ `draft` card — which is not a failure but an answer: the card's own `absent:` sources are quoted
1104
+ back as what to instrument. The two empty cases exit 3, never 0; a green meaning "I read no
1105
+ cards" is the failure this command exists to stop.
1106
+
1107
+ - **Evals v1 — five golden-transcript evals, one per stage (#26, owner decision 2026-09-01: small
1108
+ v1 now).** The suite proved the harness; nothing proved a STAGE. `test/evals/` now runs each of
1109
+ What, How, Plan, Build and Watch through the real facilitator against the scripted stand-in agent
1110
+ and asserts that stage's output CONTRACT. About 5 seconds on top of `bun test`.
1111
+ - **One stage at a time, not a chain.** Stages are sequential — Plan cannot run until How's gate
1112
+ is signed — so playing four stages to reach the fifth would make every eval depend on the ones
1113
+ before it, and a regression in What would turn all five red without any of them saying why.
1114
+ Each eval instead opens its own run on a workflow preset holding exactly one stage, which needs
1115
+ no new code: `workflowPath` already prefers `.tldrx/workflows/<scope>.yml` over the shipped
1116
+ presets, and `normalisePhase` already takes the phase folder from the stage's own `phase:`
1117
+ rather than from its position. Whatever the stage genuinely reads — a plan for Build, a done
1118
+ story for Watch — is seeded onto the disk by the scenario.
1119
+ - **Built on `learn`'s machinery, not beside it.** The stand-in `claude`
1120
+ (`src/core/learn/agentScript.ts`, `learnAgent.ts`) and the toy-repo sandbox are reused as they
1121
+ are; the real CLI is unreachable by the same two doors `learn` closes. No production code
1122
+ changed for this.
1123
+ - **Contract, not snapshot.** Nothing compares bytes. The evals assert what the FRAMEWORK
1124
+ computes: the declared outputs read back off `gate.requested`, the `checks:` outcomes and their
1125
+ computed detail (`checkPlan`'s branch-model line), the artifacts re-parsed with the framework's
1126
+ OWN validators (`validateQuestions`, `parseWatcherCard`), and the side effects a stage exists
1127
+ for — a branch cut, the story's `dod` block re-run and its exit code recorded, a `--no-ff` merge
1128
+ into the epic branch with `main` untouched, and a watcher card's status COMPUTED as `verified`
1129
+ when the scenario's front matter claimed `draft`.
1130
+ - **A check that is `skipped` for any reason other than being write-time-only now fails an eval.**
1131
+ `runCheck` falls through to `unknown check id '<x>'`, which is what a renamed or deleted check
1132
+ looks like from outside — and it would otherwise read as a pass.
1133
+ - **`EVALS` is load-bearing, not bookkeeping.** A coverage test asserts it names every stage
1134
+ under `stages/`, so a sixth stage shipping without an eval turns the suite red and says which
1135
+ one is missing.
1136
+ - **Every eval was watched failing before it was trusted.** One sabotage each, listed with its
1137
+ symptom in `test/evals/README.md`; the Plan one is the shape to aim for — nothing crashed, every
1138
+ count still matched, and dropping a cross-epic `depends_on` was still caught because the derived
1139
+ branch model changed. The same file says how to add a sixth eval, and what v1 deliberately does
1140
+ not cover: failure paths, the agent gate, attended runs, and prompt QUALITY, which needs a real
1141
+ model and a judge.
1142
+
1143
+ - **`tldrx story reopen <id> --for-fix --note "<defect>"` — a sanctioned fix round on a `done` story
1144
+ (#58, owner decision 2026-09-01).** Measured on `260829-scoring-leaderboard`: S11's adversarial
1145
+ review found a real defect (linkEmail succeeds, setDisplayName fails, the account is permanently
1146
+ linked and the score never claimable). It was accepted, small and well understood — and the story
1147
+ was already `done`, so the only choices were rejecting the whole Build stage, which destroys
1148
+ fourteen good stories' closure, or fixing it outside the story machinery, which leaves an
1149
+ epic-level commit with no story provenance. `done` → fix round was the missing arc.
1150
+ - **No attempt is consumed.** The mechanism is the one that was already there: `story.reopened` is a
1151
+ reset boundary the review ledger reads, so the approve that closed the story stops counting and
1152
+ the fix runs as attempt 1 of 2 — with both turns available to it.
1153
+ - **The same DoD and the same reviewer.** The story goes back to `todo` and the Build pipeline picks
1154
+ it up unchanged; a fix a reviewer refuses twice blocks, exactly as the original would have.
1155
+ Nothing is waved through because a human asked for it.
1156
+ - **It cannot relitigate scope.** The `--note` names a defect, and `status:` is the ONLY line the
1157
+ verb moves on the story file — the acceptance criteria the reviewer will judge against are the
1158
+ ones that were already there.
1159
+ - **One open fix round per story.** It opens on a `story.reopened` carrying the new `reason: fix`
1160
+ and closes when the story is `done` again; a second `--for-fix` while one is open is refused,
1161
+ naming who opened it and with which defect. The bound is read from `events.jsonl`
1162
+ (`ReviewLedger.fixRound`), so it holds across processes.
1163
+ - Refused when the story is **not** `done` (an unfinished story is the plain verb's job), when
1164
+ `--note` is missing, and on that second round. `reason` is written on every `story.reopened` now —
1165
+ `fix` or `attempts` — and an event without it predates the key and is an `attempts` reopen, the
1166
+ only kind that existed.
1167
+ - The plain verb's `done` refusal now points at `--for-fix` beside `reject --stage`.
1168
+
1169
+ ### Changed
1170
+
1171
+ - **tldrx is no longer sold as "lightweight" (owner decision, 2026-09-02).** The word survived from
1172
+ the concept doc, where it was true of a proposal. What it now describes is five stages, a
1173
+ machine-enforced `[src: …]` grammar that refuses a write it cannot resolve, an adversarial
1174
+ reviewer that is never the author, a dual-economy budget ledger, and a written trail from `run
1175
+ new` to `retro`, held up by a suite that measured 3093 tests across 108 files on 2026-09-02. One line replaces it everywhere, so
1176
+ the npm page, the binary's own `--help`, the plugin manifest, the README and both landing pages
1177
+ say the same thing: **an evidence-first, file-based AI development framework: five stages, a gate
1178
+ on every one, and every claim cited or refused.**
1179
+ - Changed in `package.json`, `src/cli/commands/help.ts` (the first line of `tldrx --help`),
1180
+ `plugin/.claude-plugin/plugin.json`, `plugin/skills/tldrx/SKILL.md`, `docs/concept.md`,
1181
+ `README.md`, and the docs site's `description`, hero and Spanish mirror.
1182
+ - **The status tag moved with it, because it was already stale.** 0.4.0 shipped as `beta`
1183
+ (`CHANGELOG.md`, `README.md` release table) while the badge, the README preamble, the binary,
1184
+ the plugin manifest, the site's "Where this is" section, both site footers and the generated
1185
+ release-notes preamble all still said `alpha`. They say `beta` now. The site's beta bar is
1186
+ stated as CLEARED rather than as being worked through, which is what the release table has
1187
+ claimed since 0.4.0.
1188
+ - `docs/RELEASING.md` gains the thing that made this drift invisible: the `status-…` badge is a
1189
+ hardcoded shields.io URL and does **not** update on its own, unlike the npm and CI badges
1190
+ beside it. The README's own release paragraph now shows `--tag beta`, because `release.sh`
1191
+ writes `alpha` when `--tag` is omitted.
1192
+
1193
+ - **A watcher card may name a HUMAN owner — optional, per item, never invented (#70).**
1194
+ `watch check` derived an owner from each item's own citation: `[src: api:src/Leaderboard.cs:64]`
1195
+ → `api`. That was right for v1 and stays the fallback, but it answers a different question from
1196
+ the one #70 asked — which repo EMITS a signal is not who gets paged when it stops.
1197
+ - **Additive, in both halves.** An optional front-matter `owner:` (validated only when present,
1198
+ so every card already on disk still validates) and an optional `(owner: <name>)` annotation on
1199
+ an individual item, placed BEFORE its `[src: …]` token because §2.8 makes that token the last
1200
+ thing on the line. Resolution is item → card → repo-derived, and the printed line says WHICH
1201
+ it is showing: `owner: alice (declared on the item)` versus the pre-#70 `owner: api`, left
1202
+ byte-identical for every card that declares nothing.
1203
+ - **Filled from the ledger that already names owners, or not at all.** `tldrx init` parks
1204
+ "Who owns `<repo>`?" as an `ownership` question and the answer lands in
1205
+ `.tldrx/memory/facts.yml`. The Watch prompt was inlining `observability` and `deploy` facts
1206
+ only, so a sub-agent asked for an owner had no honest source and would have invented one;
1207
+ `ownership` is now inlined, and the brief says the name may come from nowhere else.
1208
+ - **A lost name is an error, not an absence.** `(owner: )` is a card that TRIED to name somebody
1209
+ and lost it, so it is a shape issue on the card rather than a silent fall-through to the repo
1210
+ — which is the exact substitution the issue is about.
1211
+
1212
+ - **`docs/guide/08-cli-reference.md` documents `note` and `ship` (#72).** Every command in `COMMANDS`
1213
+ had a `## tldrx <cmd>` heading except three; #55 wrote `plan`'s, and these are the other two —
1214
+ `ship` being the command that opens the PR at the end of a run, and `note` how an operator records
1215
+ something against a stage. `DOCUMENTED_SUBCOMMANDS` in `test/cli.test.ts` grew to cover them, which
1216
+ is what keeps each of these gaps a red test rather than a note. Only `hook` is left out, and
1217
+ deliberately: its seven scripts are documented as the one `<script>` slot `USAGE_SPELLINGS` already
1218
+ records as a spelling.
1219
+
1220
+ - **`tldrx watch`'s one-line summary says checklist.** It read "List and re-check the watcher cards a
1221
+ run produced", which described half of what `watch check` is for and disagreed with the `--help`
1222
+ text #65 updated.
1223
+
1224
+ - **`tldrx ship` opens one PR PER REPO when the branch is in more than one (#66, owner decision
1225
+ 2026-09-01).** Since #57 a chained multi-repo run cuts ONE integration branch, `epic/<run-id>`,
1226
+ with the same name in every repo — so `ship` found it in several, every time, by construction, and
1227
+ refused with `pass one: --repo <name>`. The last step of every such run was typing the same command
1228
+ once per repo and remembering which ones had already gone through.
1229
+ - Same handoff as the body of every PR, the repo name in the title, and every URL listed at the
1230
+ end. Each PR opens against **that repo's own** `default_branch`.
1231
+ - **One repo is byte-identical**, down to the four lines it prints and the number of processes it
1232
+ spawns: the `gh pr list` probe below exists for the multi-repo case and never runs when there is
1233
+ only one. The single-repo lines are asserted as exact strings, not substrings — "we did not
1234
+ change the common case" is not a claim a `toContain` can make.
1235
+ - **A partial failure names both sides.** PR 2 of 3 failing still opens PR 3, and the report lists
1236
+ the repos that succeeded with their URLs and the repos that failed with the reason (exit `2`).
1237
+ Aborting on the first failure would leave a half-shipped run and no statement of which half.
1238
+ - **Re-running is safe.** Before creating, each repo is asked whether an open PR for the branch
1239
+ already exists (`gh pr list --head`); one that has is skipped and listed. So the fix for a
1240
+ partial failure is `tldrx ship` again and nothing else. A `gh` that fails or answers with
1241
+ non-JSON is treated as "there is none", so a transient error can never silently turn a real ship
1242
+ into a skip.
1243
+ - `--repo` still narrows to exactly one, and `ship` still never pushes: an unpushed branch in one
1244
+ repo is that repo's failure line, naming its `git push`, while the other repos' PRs still open.
1245
+
1246
+ ### Fixed
1247
+
1248
+ - **A training pass with nothing to read is no longer paid for (#101).** `runTraining` had
1249
+ exactly one "nothing to work on, refuse before the money" check and it guarded one half of one
1250
+ mode: `nothingToMineRefusal` fires only for a ROLE expert with zero minable runs. The CODE pass
1251
+ was pushed unconditionally and nothing ever looked at `selection.inlined.length`, so an expert
1252
+ whose `## Domain` matched no file on disk spawned a sub-agent, was shown no code at all, and
1253
+ wrote a knowledge file about nothing at full price. Measured on the training fixture before the
1254
+ fix, with a domain of `src/does-not-exist/`: **`{ code: 0, costUsd: 0.37, wroteKnowledge: true }`**
1255
+ — a successful run, priced, from zero input. Live cost, from #94's thread: two near-empty
1256
+ trainings at $0.82 each whose "code sweeps found nothing in-domain".
1257
+ - **The choice, named: SKIP a dead pass, REFUSE only when none survives.** That is
1258
+ `nothingToMineRefusal`'s own idiom — refuse when there is nothing to train from — without
1259
+ denying a `--mode full` run that still has one real pass. The skipped pass's reason goes to
1260
+ stderr before the money, exactly like the #96 pre-start line, and rides back in
1261
+ `TrainOutcome.warnings`; `--prepare` prints it too, and its `N sub-agent(s)` line was already
1262
+ derived from the surviving passes so it stays honest by construction.
1263
+ - **`roleTraining.ts:79`'s uncovered arm is closed.** Its guard reads
1264
+ `if (!isRole || minedFiles > 0) return null`, so a NON-role `--mode full` run against zero
1265
+ minable runs was never refused: it spawned a second sub-agent to write
1266
+ `- none [src: absent:tldrx-work]` — no evidence, no level, full price. That case is now a
1267
+ skip. The role case keeps its own, better-worded refusal untouched, because its runs pass is
1268
+ the only pass it has.
1269
+ - **It is NOT the #96/#98 preflight and does not touch it.** Underfunded and empty are different
1270
+ refusals with different remedies, so they are different checks that never consult each other.
1271
+ In particular a skipped pass does not re-divide the ceiling: the share `trainPreflight` priced
1272
+ and printed is what the surviving sub-agent gets, and the skipped one is simply not spent.
1273
+ - **Exit `1`, deliberately.** `2` is this codebase's MONEY refusal (the `MIN_TRAIN_USD` floor and
1274
+ the #96 preflight). Every "you asked for something with nothing behind it" refusal in
1275
+ `expert train` is `1` — `missingAreaRefusal`, `lightModeRefusal`, and `nothingToMineRefusal`,
1276
+ this check's literal sibling, which `test/training.test.ts` pins as "refused (exit 1)".
1277
+ - **One doc correction found while checking that.** `docs/guide/08-cli-reference.md` claimed
1278
+ `--mode light` on a role expert exits `2`. It exits `1`: `lightModeRefusal` returns
1279
+ `EXIT_USAGE`, and `src/cli/exitCodes.ts` defines `EXIT_USAGE = 1`.
1280
+
1281
+ - **`expert create` now yields an expert that can actually be trained, and states the `## Domain`
1282
+ grammar it will be read under (#94).** Live 2026-09-02: `tldrx expert create discoverer` printed
1283
+ *"no areas — every level starts at 0"*, and `tldrx expert train discoverer --area discoverer`
1284
+ answered *"has no area (areas: none)"*. Neither sentence said HOW to add one; no flag or
1285
+ subcommand existed; the sanctioned path — a block in `competencies.yml` — was discoverable only
1286
+ by reading the source. Then the same gap cost money twice more: the hand-written `## Domain`
1287
+ bullets came out WORKSPACE-relative (`Scavtopia.Workflows/src/…`) because nothing documents the
1288
+ grammar, so **a $2.10 full training earned zero evidence — all 13 of its code citations read
1289
+ `outside domain`** — plus two near-empty $0.82 runs whose code sweeps found nothing in domain.
1290
+ - **`--area <id>` seeds the first competency area, and `--title <text>` names it.** The title is
1291
+ not decoration: light mode greps the words of the area title to choose which files the expert
1292
+ is shown (`training/selectFiles.ts`), so `create` prints the default title it chose and names
1293
+ `--title`. An area id obeys the same slug rule the expert name does — it is a filename
1294
+ (`knowledge/<area>.md`) and half of a copy-pasteable `--area` argument.
1295
+ - **Both refusals name the FILE and print the block.** `expert train` on a missing area now
1296
+ answers with `.tldrx/experts/<name>/competencies.yml` and the five keys to paste (`id`,
1297
+ `title`, `level: 0`, `train_prompt`, `evidence: []`), from one helper both the CLI and
1298
+ `runTraining` use, so the two paths cannot drift. `create` with no area says the same thing.
1299
+ - **`create` writes the front-matter `repos:`**, read off `.tldrx/workspace.yml` (`repos: []`
1300
+ when there is none, rather than a guess). `expert.md` had no such key at all, so a
1301
+ hand-created expert declared nothing about which repos its `## Domain` bullets are relative
1302
+ to — and that is exactly the thing the bullets are relative to.
1303
+ - **The created `expert.md` states the grammar in its own `## Domain` section**: bullets are
1304
+ repo-RELATIVE with no repo prefix, a whole-repo claim is `` - repo `api` ``, citations arrive
1305
+ as `repo:path:line` so `api:src/Checkout/Cart.cs:12` is matched by `` `src/Checkout/` `` and
1306
+ **not** by `` `api/src/Checkout/` ``. It is written as prose, deliberately: a worked example
1307
+ written as a BULLET would itself be parsed as a declared domain path, and an expert that
1308
+ silently claims `api/src/Checkout` puts every real citation outside its own domain — the bug,
1309
+ not the fix. Pinned by a test that the created file still parses to zero domain paths.
1310
+ - **The `outside domain` warning names the grammar FIRST** when the cited path would match with
1311
+ one leading segment dropped from either side — the bullet carrying a repo prefix it should
1312
+ not, or the citation being workspace-relative where a `repo:path:line` was wanted. It says the
1313
+ measured fact (`X` without its first segment DOES contain this path), not a diagnosis.
1314
+ - **And naming another expert no longer reads as exclusivity.** *"train that expert on it
1315
+ instead"* was measured false: overlap between domains is legal, and the hint fires whenever
1316
+ this expert's own paths miss. It now reads *"also declares a domain that contains it, so it
1317
+ may be the better home for this one (overlap is legal: this fired because none of `<expert>`'s
1318
+ own paths match)"*.
1319
+ - **`init/loadWorkspaceFile.ts` reads `.tldrx/workspace.yml`'s name from `core/paths.ts`**
1320
+ (`PROJECT_WORKSPACE_FILE`, the constant #92 added) instead of from `init/runInit.ts`;
1321
+ `runInit` keeps exporting `WORKSPACE_FILE` under that name, aliased to the same string, so no
1322
+ import site changed. `loadWorkspaceFile` is the small leaf several commands read the repo list
1323
+ through, and taking that one constant off `runInit` dragged the whole of `init` — detection,
1324
+ map providers, the MCP probe — into every module that read it: `dist/hooks/session-start.js`
1325
+ went 37,937 → 54,467 bytes and blew the 50 KB entry-point cap `test/build.test.ts` enforces.
1326
+ It is back to 37,937.
1327
+ - Documented in `docs/spec.md` §2.3 and §2.8, `docs/guide/04-experts.md`,
1328
+ `docs/guide/08-cli-reference.md`, `docs/guide/09-troubleshooting.md` and both docs-site expert
1329
+ guides — the grammar had never been written down anywhere outside `expertDomain.ts`.
1330
+
1331
+ - **`release.sh` ran the gate AFTER pushing the release commit to `main` (#100).** The order was
1332
+ commit → push `main` → `release-check.sh` → tag → push tag, so every red item the gate has —
1333
+ tests, typecheck, build, the `Bun.*` seam grep, "tag already exists", "already on npm", "tree
1334
+ not clean" — landed a `release: X.Y.Z` commit on `origin/main` carrying a **dated** CHANGELOG
1335
+ heading and a **dated** README row, with no tag behind it. That is precisely the half-released
1336
+ state checklist item 4 exists to prevent, and it costs a revert commit on `main` or a
1337
+ hand-repaired CHANGELOG to undo. The order is now commit → **gate** → push `main` → tag → push
1338
+ tag: a red gate leaves `origin/main`, the tags and npm untouched, the whole of the damage is one
1339
+ local commit, and the script says so and prints the one command that drops it.
1340
+ - **The gate did not have to be weakened to move it.** Of its items only "in sync with
1341
+ `origin/main`" assumes the push already happened; run against an unpushed release commit it
1342
+ would be permanently red, which is the trap in the naive reorder. `release-check.sh` takes a
1343
+ new `--pre-push` that restates that one item as **"`origin/main` is HEAD's parent"** — the same
1344
+ assertion (nobody moved `main` under you, nothing but the release commit is unpushed) for a
1345
+ tree that has not pushed yet, and one notch stronger, since it also refuses a second unpushed
1346
+ commit. Items 1–3 already needed the edits, which exist by then; "working tree clean", "on
1347
+ main", "tag does not exist", "not on npm", typecheck, tests, build and the seam grep are
1348
+ untouched and all still run. **No flag, no default, no CI path changed**: bare
1349
+ `release-check.sh` (the `release-gate-hook.sh` PreToolUse deny) and `--ci` (publish.yml)
1350
+ behave exactly as before, and there is a test for each.
1351
+ - **`sed -i ''` was macOS-only**, which is why this path had never had a test: measured against
1352
+ GNU sed 4.9 in `debian:stable-slim`, `sed -i '' -E …` exits **2** with `sed: can't read
1353
+ s/^## …/: No such file or directory`, so `release.sh` could not run on the ubuntu CI runner at
1354
+ all. Both substitutions now go through a temp file outside the tree and are `cat` back (inode,
1355
+ mode and a clean working tree preserved) — byte-identical output on BSD sed, and it runs on
1356
+ GNU sed, which is what lets `test/release-gate-order.test.ts` hold the ordering in CI. The new
1357
+ file runs the real two scripts against a real bare "origin" with only `bun` and `npm` stubbed,
1358
+ and reads the ordering off the shas the gate actually ran against: pre-fix, HEAD and
1359
+ `origin/main` were EQUAL at gate time; now they differ by the release commit.
1360
+
1361
+ - **The prose was validated against the binary, top to bottom, and a lot of it was false.**
1362
+ The last docs QA (3ee3723) was a release ago; `watch check`/`watch arm`, `update` and
1363
+ its notice, the `drive` preflight, `plan schema`, `questions cards`, `story reopen --for-fix`,
1364
+ the host-token economy and the dashboard's new sources have all landed since. Every claim in
1365
+ `README.md`, all thirteen English pages, all thirteen Spanish pages and `CONTRIBUTING.md` was
1366
+ cold-read for lies first, then checked against `tldrx <cmd> --help` and the source. What was
1367
+ found was not drift at the edges — most of it was load-bearing.
1368
+ - **A flag that does not exist.** The README's cost section explained what `--max-budget-usd`
1369
+ does. That is the flag tldrx passes DOWN to `claude`, not one a user may type: `tldrx next
1370
+ --max-budget-usd 5` answers `unknown flag`. The user-facing flag is `--max-usd`.
1371
+ - **"Every run-targeting command exits `2`" was wrong twice**, in the README, the CLI reference
1372
+ and the FAQ. `tldrx cost` refuses at exit **1** (`cost.ts` returns `EXIT_USAGE`, and its own
1373
+ `--help` never lists a 2), and `tldrx run status` does not refuse at all — it lists every open
1374
+ run and exits **0**, which is the whole point of it, since it is the screen you read to find
1375
+ the id the others are asking for. All three pages also named five commands as taking a
1376
+ positional `<run>`; the real split is that most take either form, `replay` and `retro` take
1377
+ the positional only (`--run` there is an unknown flag, measured), and seven take `--run` only.
1378
+ - **A documented command that silently creates a directory where you asked for a file.**
1379
+ `tldrx dashboard --static --out ./somewhere/page.html` was in the reference. `--out` is a
1380
+ directory and the filename is not negotiable (`writeStatic.ts` joins `index.html` and
1381
+ `mkdirSync`s the path), so that line makes a directory literally named `page.html`.
1382
+ - **A required tool the Quickstart did not name.** `env.yml` declares four `required: true`
1383
+ tools; the install page named Node, `git` and `claude` and never mentioned **Bun ≥ 1.3**, so a
1384
+ reader who followed it exactly got `tldrx doctor` exit 1 and no explanation.
1385
+ - **The `tldrx learn` chapter list was stale AND swapped** on both language Quickstarts: 6 and 7
1386
+ are `the agent gate` and `attended`, and the pages had two other titles in the other order —
1387
+ so `--chapter 7` sent a reader looking for the agent gate to the wrong chapter.
1388
+ - **Counts that had grown.** The `.gitignore` block `init` writes excludes **eight** paths, not
1389
+ the five the README listed. The `[src: …]` grammar has **eight** kinds, not the seven the
1390
+ evidence table showed — `aidlc:` was missing while the page's own next paragraph said
1391
+ "eight". The What, How and Plan stages each declare `questions.md` as an output and Plan also
1392
+ declares `budget.yml`; the stages table showed none of them, which matters because the auto
1393
+ gate is measured on exactly that file. `tldrx init` seeds **three** kinds of expert — role,
1394
+ stack and **domain** — and both expert sections said two.
1395
+ - **Twelve `workflows/*.yml` said the auto gate has "the five spec §5 conditions"** and listed
1396
+ five. `evaluateAutoGate` has had **seven** since the stories and boundary conditions landed.
1397
+ - **The `tldrx drive` mandate's preflight (#84) was absent from every page that enumerates what
1398
+ the mandate carries**, in both languages, and the English driving guide still taught the
1399
+ three-command recipe the preflight replaced.
1400
+ - **`CONTRIBUTING.md` overstated the ref guard and understated the lock.** The guard refuses a
1401
+ ref move while ANOTHER invocation holds the lock — the holding run's own git children carry
1402
+ `MW_LOCK_TOKEN` and pass, which is what lets the wave commit and push through its own guard;
1403
+ as written it would have refused the merge it exists to protect. `merge-wave.sh`'s seven exit
1404
+ codes were documented nowhere, in a file whose own rule is to read exit codes. The lock's wait
1405
+ is bounded (`MW_LOCK_WAIT_S`, default 3600 s, then exit 6) and `CLAUDE.md` implied it was not.
1406
+ - **The Bun/Node seam rule was stated more broadly than it is enforced**, in `CONTRIBUTING.md`,
1407
+ `CLAUDE.md` and `docs/RELEASING.md`: the grep scans `src` only, and `scripts/build.ts` calls
1408
+ `Bun.build` for a living. A contributor reading it literally files a bug against the build.
1409
+ - **`publish.yml` does not "run the same check".** `release-check.sh --ci` skips items 4 and 5
1410
+ entirely; the seam check runs in no CI workflow at all. Said plainly now, in both the README
1411
+ and `docs/RELEASING.md`.
1412
+ - **Four translated strings that had to stay English** in the Spanish mirror: two `--note`
1413
+ samples, the `## Qn · Title` heading the §2.7 parser matches on, and `$0.00 of $25.00`, which
1414
+ is what the dashboard actually renders.
1415
+ - Fixed on the way: the `0.5.0` CHANGELOG section had grown **two `### Fixed` groups** and had
1416
+ two additions (Evals v1, `story reopen --for-fix`) filed under `### Removed` — an artefact of
1417
+ unioning sibling branches. One group per kind now, in the order `0.3.0` used, with every entry
1418
+ byte-identical and only the duplicate heading gone.
1419
+ - Two defects found while checking and **filed rather than fixed**, both outside this pass:
1420
+ [#99](https://github.com/ederwii/tldr-experts/issues/99) `test/merge-wave.test.ts` asserts over
1421
+ a shared `TMPDIR`, so a sibling process fails it (measured: red under load, green alone, and
1422
+ green for the full suite with no sibling), and
1423
+ [#100](https://github.com/ederwii/tldr-experts/issues/100) `release.sh` pushes the release
1424
+ commit to `main` before it runs `release-check.sh`.
1425
+
1426
+ - **`expert train` says what it is about to spend, on which model, BEFORE it spends it (#96).**
1427
+ Live 2026-09-02: `tldrx expert train discoverer --area discoverer --mode full` inherited the
1428
+ claude CLI's last-used model — `fable-5`, a premium tier — and ran against the default ceiling.
1429
+ Full mode splits that ceiling between its two sub-agents, so the code pass was handed $1.00. It
1430
+ died with `Reached maximum budget ($1)` at 54 s, **$1.31 spent and recorded, nothing written to
1431
+ `competencies.yml`.** Three defaults compounded and not one of them was said out loud.
1432
+ - **A pre-start line names the model, its tier, and where the name came from** — `--model`,
1433
+ `$ANTHROPIC_MODEL`, or a `model:` key in `.claude/settings.local.json` /
1434
+ `.claude/settings.json` / `~/.claude/settings.json`: `model claude-fable-5[1m] (premium,
1435
+ inherited from your claude CLI via ~/.claude/settings.json) — pass --model to override ·
1436
+ --mode full · $3.00 across 2 sub-agent(s), $1.50 each`. When nothing on the box says which
1437
+ model the CLI will pick, the line says THAT rather than inventing a tier.
1438
+ - **A refusal — exit `2`, nothing spawned, nothing spent — when the share cannot fit and the
1439
+ ceiling is the DEFAULT one.** The test is arithmetic, not a category: does the money one
1440
+ sub-agent gets reach what one pass on that tier costs? Measured full trainings run
1441
+ **$1.21–$1.60 end to end on a mid model** (two `training.jsonl` lines on `aparece-platform`,
1442
+ `docs/audits/2026-08-29/experts-knowledge.md` §E, plus the top of the band in #96), so one
1443
+ pass is ~$0.70 mid and ~$1.76 premium (opus lists at 2.5x sonnet). Full mode's $1.50 a pass
1444
+ does not reach it. The refusal names both remedies — `--model sonnet`, or `--max-usd <n>`.
1445
+ - **An explicit `--max-usd` is never refused.** The operator looked at the number; that is the
1446
+ whole decision this check exists to ask for. It warns in one line and proceeds.
1447
+ - **The full-mode default `--max-usd` is now $3.00**, up from the $2.00 light mode still uses,
1448
+ because full mode pays for two sub-agent passes and the one repair round a rejected knowledge
1449
+ file earns comes out of the same share. $1.50 a pass is ~2x the measured per-pass midpoint.
1450
+ Deliberately not scaled higher for a premium model: `--max-budget-usd` is a stop after the
1451
+ turn, not a cap (spec §2.6.1 — a $1.50 ceiling has realised $5.15), so a bigger default cannot
1452
+ make a premium turn affordable, only more expensive to lose. `budget-gate` prices `--mode
1453
+ full` at $3.00 to match.
1454
+ - Salvaging the partial evidence a budget death throws away is a design call and was NOT built;
1455
+ the question is asked on #96.
1456
+
1457
+ - **`expert train --prepare` carries that check into the bundle (#98).** The check above was wired
1458
+ to the spawning path only, and `--prepare` spawns nothing — but it writes the ceiling into
1459
+ `pending.json` (`max_budget_usd: 1.50`) and into the prompt text (`Ceiling for this sub-agent:
1460
+ $1.50`), and a host session then spends against it. Measured on `b5d59c5`: a full `--prepare` with
1461
+ a premium model inherited from `~/.claude/settings.json` printed `$1.50 ceiling each`, returned
1462
+ `preflight: null`, and wrote two bundles saying `model: null` — the same trap, one command later,
1463
+ on the host's money.
1464
+ - The model line and any warning now go to **stdout with the prepared block** and into an optional
1465
+ **`preflight`** key on each bundle's `pending.json`. Absent when there is no warning, so an
1466
+ unremarkable bundle stays byte-identical to the one this command has always written.
1467
+ - **What it may CLAIM differs by half.** Headless, tldrx spawns `claude` with no `--model`, so the
1468
+ CLI's default is a prediction about a process this code starts: the refusal stands. On
1469
+ `--prepare` an **explicit `--model`** is an instruction written into the bundle and is refused
1470
+ the same way (exit `2`, no bundle written); an **inherited** model only warns, naming both
1471
+ remedies — refusing a bundle over a settings-file key, for a session tldrx does not control,
1472
+ would be asserting more than is known. `--commit` says nothing: that money is already spent.
1473
+
1474
+ - **`test/merge-wave.test.ts` no longer fails a wave over another wave's log directory (#95, #97).**
1475
+ `merge-wave.sh` writes its logs to `${TMPDIR:-/tmp}/mw-$$`, and the test that asserts a green run
1476
+ cleans up after itself diffed a listing of the machine's SHARED tmpdir. Every wave on the box
1477
+ writes there — this file's other tests, every sibling agent's wave, and the real merge wave whose
1478
+ `bun test` is running this very file — so the assertion was about ONE run's cleanup and measured
1479
+ the whole machine. It went red twice on trees whose diff touches neither the script nor its tests:
1480
+ **#95**, on a sibling's DELIBERATELY KEPT red-wave log (`mw-35458`, carrying another run's
1481
+ `poison.txt` merge — merge-wave keeps a failed run's logs on purpose, "every FAIL above names the
1482
+ directory it kept"); and **#97**, on a CONCURRENT invocation's LIVE `mw-15412`, twelve minutes into
1483
+ #90's wave, with `main` left at an unpushed merge commit. Both cost a full re-gate, and the same
1484
+ trees passed standalone.
1485
+ - **Every invocation now gets a private `$TMPDIR` inside its own sandbox**, so "did THIS run clean
1486
+ up after itself" has an answer that does not depend on what else the box is doing. Per
1487
+ invocation, not per sandbox: this file deliberately runs two waves at once. The scan is read only
1488
+ after the run has exited, so anything left in that root is a genuine leftover and no liveness
1489
+ rule has to be guessed at — a concurrent wave's live `mw-<pid>` is, correctly, invisible from
1490
+ there.
1491
+ - **What the test asserts about merge-wave is unchanged.** A green run that stops removing its log
1492
+ directory still fails it, and a red run that stops keeping the one it names still fails it — both
1493
+ re-measured by mutating `scripts/merge-wave.sh` and watching the assertions fire.
1494
+ - **A red run's kept logs now leave with the sandbox.** They were being written to the machine's
1495
+ tmpdir and never removed — 1300 `mw-*` directories had accumulated there by the time #95 was
1496
+ filed, including the one it tripped on.
1497
+
1498
+ - **A story's first attempt is dispatched at what the plan priced it, not at that figure halved
1499
+ again (#91).** `03-plan/budget.yml` has been read since 2026-08-30, but the price it carries was
1500
+ divided by the worst case ONE story can be asked for — `MAX_ATTEMPTS × (1 + REVIEWER_SHARE)` =
1501
+ 2.5 — before a single attempt had run. Measured on run `260901-leaderboard-v2` (finding F-4):
1502
+ Delivery priced S2 at $2.10 of a $3.85 Build stage and the developer was dispatched under
1503
+ **$0.84**. A deliberately-atomic large story starved on the one attempt that mattered while
1504
+ trivial ones carried slack, and the plan's own measured pricing — the whole point of writing the
1505
+ file — was thrown away in the arithmetic that read it.
1506
+ - **`developerPriceDivisor(attempt)`** now decides it. Attempt 1 is the pass Delivery priced and
1507
+ gets `price / (1 + REVIEWER_SHARE)` — the whole price less the reviewer's derived quarter, so
1508
+ S2's ceiling goes $0.84 → **$1.68**. Attempt 2 is a contingency nobody priced and keeps the
1509
+ pre-#91 figure, `price / (MAX_ATTEMPTS × (1 + REVIEWER_SHARE))`. No attempt is ever handed less
1510
+ than it was handed before.
1511
+ - **The even split is untouched.** A plan with no `budget.yml`, one that does not validate, one
1512
+ priced in `host-tokens`, and any story the plan did not name all still get
1513
+ `stage / (stories × attempts × 1.25)`, pinned by tests that were green before this change and
1514
+ are green after it.
1515
+ - **What this costs, stated.** The worst case one PRICED story can be asked for goes from
1516
+ `0.8 × price` to `1.2 × price`. The phase ceiling is metered once, at stage entry —
1517
+ `runNext.runExecutor` skips the brake while a stage is `running` — so nothing re-checks the
1518
+ envelope between two spawns of the same headless `runAll`; that is the window
1519
+ `REVIEWER_FLOOR_USD` already opens by design. `priceScale` still holds the sum of the declared
1520
+ prices inside the stage, and `remainingWork` still clamps the brake's estimate to the stage's
1521
+ own price, so the brake can never refuse more often than it used to. The measured REMAINDER of
1522
+ a story's price would be tighter on the second attempt, and is recoverable (`agent.result`
1523
+ carries `key` = the story id and a row-level `cost_usd`) — it is not read, because the
1524
+ budget-gate hook's `remainingWork` would have to read the same ledger on its hot path to stay
1525
+ in step, and the worst case is `0.8 + 0.4` either way.
1526
+ - **`remainingWork` mirrors the schedule, turn by turn.** `RemainingStory` gains
1527
+ `developerCapsUsd` — one cap per turn still to dispatch, in the order they run — because
1528
+ `cap × turns` stopped being the truth. The turns still to run are the LAST of the story's run
1529
+ of attempts, so a story with one attempt behind it is priced as the attempt 2 it is about to
1530
+ become. `renderRemainingWork` prints `dev $1.20 ×2` while the turns cost the same and
1531
+ `dev $3.60 + $1.80` once they do not, so the total on the line still adds up. A test asserts
1532
+ the mirrored divisor equals the executor's own, as it already did for the three constants.
1533
+
1534
+ - **The merge lock now has something to say about raw git in the shared checkout (#89).** The
1535
+ lock serialises merge-wave INVOCATIONS; it never serialised git. Measured 2026-09-02: while
1536
+ agent A's gates were running, agent B typed `git reset --hard origin/main` into the same
1537
+ shared checkout — reflog `reset: moving to origin/main` — and A's merge commit stopped being
1538
+ reachable from `main` mid-gate. #44's gated-HEAD assertion fired for the first time in anger
1539
+ and refused to push. The aftermath was caught; the damage was not prevented.
1540
+ - **`scripts/merge-guard.sh`, installed as a `reference-transaction` hook on every
1541
+ merge-wave run.** That is the only hook git will let abort work in progress: it fires for
1542
+ every reference update and, in its `prepared` state alone, aborts the whole transaction on
1543
+ a non-zero exit. Measured on git 2.50.1 with the guard refusing — `git reset --hard`,
1544
+ `git checkout -B main`, `git merge --no-ff`, `git commit` (including `--no-verify`, which
1545
+ does **not** bypass it) and `git update-ref` all exit 128 with `fatal: ref updates aborted
1546
+ by hook`, and **the ref does not move**. There is no pre-reset, pre-checkout or
1547
+ pre-merge-anything hook; this is the strongest mechanism git offers without a daemon.
1548
+ - **Where the line honestly lies, because it is not where it looks.** `git reset --hard`
1549
+ writes the WORKING TREE before it opens any ref transaction — measured: the file content
1550
+ had already changed to the target commit's while HEAD still pointed at the old one. So the
1551
+ guard saves the **commit**, which is what #89 lost, and cannot save the checked-out files;
1552
+ a wave whose worktree is clobbered mid-gate still gates a tree nobody meant it to, and its
1553
+ gated-HEAD assertion will not fire, because HEAD is exactly where it left it. `git merge`
1554
+ is the better case — refused before the worktree is touched at all. And a hook in a
1555
+ checkout is bypassable by anyone willing to: `git -c core.hooksPath=…`, deleting the hook,
1556
+ or exporting `MW_LOCK_TOKEN`. It is built against accidents, not intent.
1557
+ - **The lock is the sentinel; the marker is what that lock looks like to a human.**
1558
+ `merge-wave.sh` writes `.MERGE-WAVE-IN-PROGRESS` at the root of the shared checkout naming
1559
+ the branch, the owning pid and host, and the way back in. Gitignored, so a wave cannot trip
1560
+ its own dirty-tree guard; removed by `release()` on every path out, INT and TERM traps
1561
+ included. The guard keys on the LOCK rather than on the marker, because only the lock
1562
+ carries an owner and only an owner can be tested for death — a SIGKILLed wave must not
1563
+ leave a file behind that wedges the checkout.
1564
+ - **Scope, so an agent's own work is never in the way.** In the shared checkout every ref
1565
+ update is refused while a foreign wave holds the lock. From a linked worktree only
1566
+ `refs/heads/main` is — refs live in the common dir, so `git update-ref refs/heads/main`
1567
+ typed in a worktree destroys the wave just as thoroughly (measured: it succeeds, unguarded).
1568
+ The holder's own git is recognised by an exported token written inside the lock, so
1569
+ merge-wave cannot deadlock itself, and a lock whose owner is dead is ignored exactly as the
1570
+ waiting loop already ignores it — `scripts/merge-lock.sh` is that one vocabulary, shared by
1571
+ writer and guard so the two cannot drift apart.
1572
+ - **The state name git passes is not portable, and assuming it was shipped a red CI.** macOS
1573
+ git 2.50.1 calls the abortable state `prepared`; the Linux runner's git calls it something
1574
+ else (`fatal: in 'preparing' phase, update aborted by the reference-transaction hook`, run
1575
+ 33589554234 on `e0e76a2`). The guard's `*)` arm printed a usage error and exited 2, so on
1576
+ that machine every ref update in every sandbox was refused and the whole merge-wave suite
1577
+ went red at once while macOS stayed green — #49's failure shape exactly. It now recognises
1578
+ `committed` and `aborted` and treats **everything else** as the prepare state, which cannot
1579
+ repeat the blanket refusal: git honours the exit code in the prepare state only, so an
1580
+ unrecognised name is either handled correctly or ignored. The test asks git which states it
1581
+ passes rather than hardcoding them, and additionally probes the names this machine does NOT
1582
+ use — the only way the portability property is testable anywhere.
1583
+
1584
+ - **And the convention it only approximates is now written down**, verbatim and identically,
1585
+ in `CONTRIBUTING.md` and `CLAUDE.md`: agents touch the shared checkout ONLY through
1586
+ `scripts/merge-wave.sh`, every other piece of work happens in their own worktree, and never
1587
+ `git reset --hard` or `git checkout -B main` in the shared checkout. A test asserts both
1588
+ files still say it.
1589
+
1590
+ - **A cancelled run is no longer told to retry itself (#86).** `tldrx run cancel` is the way to
1591
+ close a run whose stage FAILED — that is the case `run.cancelled` exists for, and it is a
1592
+ run-level field precisely so the stages keep their failure, "which is history, not state".
1593
+ `deriveRunStatus` read that decision first; `waitingFor` could not read it at all. It is typed
1594
+ against `WaitingRun`, the smallest shape both `RunFile` and the dashboard's tolerantly-read
1595
+ `RunDocument` satisfy, and that shape had `cursor` and `phases` and nothing else — so the answer
1596
+ came from the status of the stage at the cursor, which a cancellation deliberately leaves
1597
+ `failed`. A run somebody had closed reported `waiting.kind: failed` and
1598
+ `"… FAILED — retry: \`tldrx next\`"`, counted as movable, and could wear `← next` on the
1599
+ dashboard. Both screens, because both have read the same derivation since #60.
1600
+ - `WaitingRun` grows an optional `cancelled`, `RunDocument` projects it, and `waitingFor` reads
1601
+ it FIRST — before the cursor checks, in the same order and for the same reason
1602
+ `deriveRunStatus` does. A ninth waiting kind, `cancelled`, joins `WAITING_KINDS`; it is not in
1603
+ `MOVABLE_KINDS`, so `tldrx status` stops offering the run and the dashboard raises no card.
1604
+ - The message names **who closed it, when, and their note** — three facts that were dropped
1605
+ everywhere, on the one screen that exists to say what a run needs. It offers no command:
1606
+ `tldrx next` on a cancelled run already advances nothing, and printing it is what made an
1607
+ operator think there was something left to do.
1608
+
1609
+ - **An out-of-order `--commit` no longer leaves a `stage.started` behind (#87).** `runExecutor`
1610
+ stamped the stage `running` and appended `stage.started` on its way in, before the executor had
1611
+ had a chance to say anything. #82 restores the stage's STATUS when that executor turns out to be
1612
+ refusing a sequencing mistake, so `run.yml` comes back byte for byte — but `events.jsonl` is
1613
+ append-only truth and the line could not be unwritten. The live run `260901-leaderboard-v2` left
1614
+ one at 2026-09-02T00:36:37Z: a `stage.started` with no matching `stage.done` or `stage.failed`,
1615
+ which `renderReplay` and anything counting starts read as a start. A `--commit` never STARTS a
1616
+ stage — it settles a cycle a `--prepare` started, and on the ordinary commit the stage is
1617
+ already `running` and no event was emitted anyway — so it no longer emits one at all. The
1618
+ `markRunning` STAMP stays, keeping the original `started_at`, so a `--commit` that does settle a
1619
+ cycle on a stage something demoted still finishes through. The refusal now precedes the event by
1620
+ construction: there is no event. Pinned by measuring the WHOLE log, not a `stage.started` count.
1621
+
1622
+ - **An unreadable `result.json` is refused like an absent one, loudly (#88).** Owner decision,
1623
+ 2026-09-02. #82 split `PendingError` into `absent` (the host has not written the file) and
1624
+ `unreadable` (the host wrote it and got it wrong), and left the second failing the stage. But
1625
+ nothing was attempted there either — no sub-agent ran, no cent moved, no branch changed — and
1626
+ the fix is the same single command: rewrite the file, run `tldrx next --commit` again. Failing
1627
+ it actively OBSTRUCTED that fix, because it demoted the stage out of `running`, and the
1628
+ phase-budget gate is skipped exactly when a stage is `running` — which is how #82's live run
1629
+ took a `budget.blocked` it had not earned. A host that fat-fingers its JSON paid the same tax.
1630
+ It is #79's model — FORM never costs an attempt, CONTENT/WORK always does — applied to run
1631
+ state.
1632
+ - Corruption does not pass silently. Both call sites in `build.ts` now go through one door that
1633
+ appends a typed **`result.unreadable`** event naming the run-dir-relative path, the parser's
1634
+ own message, the role (`developer` or `reviewer`) and the story, and the refusal line says
1635
+ which file to rewrite and which command to run. It is the only thing a sequencing refusal ever
1636
+ writes; `run.yml` still comes back byte for byte, and `tldrx replay` renders the line.
1637
+ - `parseReview`'s fail-closed rule is untouched and is a different, harsher contract: it governs
1638
+ an envelope that PARSES and is not a valid verdict (unreadable ⇒ `changes`, never `approve`).
1639
+ A file that does not parse at all never reaches it.
1640
+
1641
+ - **Watch READS the branch it diffs off `run.yml`; it used to derive one (#90).** The watch stage
1642
+ diffed `feature.epic?.branch` — the `branch:` an epic file DECLARES, written at Plan time before a
1643
+ line of code exists. Under the integration branch model (#57) the Build executor deliberately
1644
+ ignores that value: every epic's stories merge into one `epic/<run-slug>` branch and the epic
1645
+ stays in the plan as a label. So on `260901-leaderboard-v2` the prompt told BOTH watchers,
1646
+ verbatim, that `epic/leaderboard-v2-api` *"does not resolve in scavtopia-workflows. Treat this
1647
+ feature's code as UNSEEN — cite `absent:` rather than guessing at what it emits"* — about a branch
1648
+ nothing had ever cut, while `build.epic_branch` recorded the real one three lines away in the same
1649
+ run. An obedient watcher would have written an all-`absent:` card, and **that card PASSES
1650
+ `claim-sources`**, because an `absent:` citation resolves by construction. Confident, validated,
1651
+ useless coverage; caught only because the host's brief carried the real branch independently.
1652
+ - **The branch now comes from the run's record and nowhere else** (`core/watch/recordedBranch.ts`).
1653
+ `branch_model: integration` → the run's one recorded branch, for every feature. `per-epic`, and
1654
+ every run written before `branch_model` existed → the recorded LIST, with the epic's declaration
1655
+ used *only as a key into it*. A declaration is never returned as a branch: either it is in the
1656
+ record and the record's entry is what is returned, or the answer is "unrecorded".
1657
+ - **A branch the record CLAIMS and the repo cannot find is now a refusal**, not an instruction.
1658
+ It is the run contradicting itself, so the stage refuses before anything spawns (`refused`, so
1659
+ the stage goes back to `ready`), naming the recorded value, the feature and the repo, and saying
1660
+ what fixes it. The treat-as-UNSEEN instruction survives for the one honest case — the record
1661
+ names no branch at all — where the prompt now cites `build.epic_branch` rather than a name it
1662
+ made up. The two absences used to render as the same sentence.
1663
+ - Thirteen tests, red first (6 red / 6 green before, 13 green after): the derivation reproduced
1664
+ under both branch models (the prompt's branch lines asserted byte for byte against
1665
+ `build.epic_branch`), the loud path in both `headless` and `--prepare`, `--commit` deliberately
1666
+ NOT refusing a turn that is already paid for, and the honest absence.
1667
+
1668
+ - **A recorded `default_branch` that does not resolve is incoherent state too (#92).** #90 taught
1669
+ Watch to tell a run that recorded NO branch (honest) from a branch its own `build.epic_branch`
1670
+ claims and the repo cannot find (incoherent, refused). It left the third case alone: the BASE of
1671
+ the same diff. `.tldrx/workspace.yml` declares `default_branch: main` for a repo where `main` does
1672
+ not resolve, and the prompt rendered *"`main`, the `default_branch` of api, does not resolve
1673
+ there. Treat this feature's code as UNSEEN — cite `absent:` rather than guessing at what it
1674
+ emits"* — inviting the same all-`absent:` card that passes `claim-sources` and covers nothing.
1675
+ Narrower blast radius than #90 (a misdetected or renamed default branch, not an every-run
1676
+ derivation), same shape: **a value the workspace RECORDS that the repo cannot find.**
1677
+ - **Watch refuses at `--prepare` and headless**, naming the repo, the recorded value and
1678
+ `.tldrx/workspace.yml`, and saying that the value was DETECTED from that repo — so a repo with
1679
+ no such branch means the record has gone stale (a rename, a fresh clone with only remote
1680
+ branches, a misdetection). `epicDiff` carries it as `baseMissing`, the sibling of #90's
1681
+ `branchMissing`, and checks the base FIRST: a base that is not there voids every diff in that
1682
+ repo rather than emptying one, so there is nothing to learn by asking about the branch. On
1683
+ `--commit` it stays tolerant, the same call #90 made — those cards are already paid for.
1684
+ - **`tldrx doctor` reports it**, which is where the issue asked whether it belonged, and the
1685
+ answer is yes: this is a workspace RECORDING something false, and `doctor` is the one command
1686
+ whose job is what this machine and this workspace actually have. One
1687
+ `git rev-parse --verify --quiet` per declared repo, nothing fetched or written. A **warning**
1688
+ that never moves the exit code — `healthy` is about the TOOLS this machine has, and a repo can
1689
+ legitimately be mid-clone or mid-rename on a developer's box; a `doctor` that exits 1 for that
1690
+ is a `doctor` people stop running. A repo that is not on disk, has no `default_branch` recorded,
1691
+ or is not a git repo is reported as SKIPPED with its reason, never folded into "all resolve".
1692
+ - **`boundary` has the gap and deliberately keeps its verdict — measured, not assumed.** It does
1693
+ diff `<default_branch>...<epic_branch>` (`boundary.ts`, `base:` read from `workspace.yml`;
1694
+ changing the record from `main` to `trunk` changes the target's `base` byte for byte), and an
1695
+ unresolvable base came back `{ok: true, detail: "n/a (nothing could be diffed: \`trunk\` does not
1696
+ resolve in app)"}` — the gate passes GREEN and stops measuring for as long as the record is
1697
+ wrong. It is **not** made to refuse: it spawns nothing and writes nothing, so the fault costs it
1698
+ a measurement rather than producing a false one, and `boundary.ts`'s own contract is that it
1699
+ "must not refuse a gate for a reason that has nothing to do with the boundary" — one stale
1700
+ record would otherwise brick every Build gate in the workspace. What changed is the WORDING: its
1701
+ two absences used to be the same sentence (`` `<ref>` does not resolve in <repo> ``), so a
1702
+ merged-and-deleted epic branch (nothing to fix) read exactly like a stale `default_branch`
1703
+ (`n/a` until someone fixes it). The base's reason now names the record and points at
1704
+ `tldrx doctor`; the branch's is untouched.
1705
+ - `.tldrx/workspace.yml` is spelled once, in `paths.ts`, now that three operator-facing messages
1706
+ tell someone to go and edit it.
1707
+ - Fifteen tests, red first (8 red / 7 green before, 15 green after), including the verbatim
1708
+ pre-fix string captured off the real prompt, the `--commit` tolerance, doctor's warning level
1709
+ asserted through `runDoctor` end to end, and the boundary determination pinned on all four
1710
+ counts — that it reads `default_branch`, that it stays `ok`, that its reason now names the
1711
+ record, and that a missing epic branch keeps its own wording.
1712
+
1713
+ - **The dashboard says the framework's CURRENT vocabulary, not 0.2.0's.** The live page
1714
+ shipped in 0.2.0 and has had one change since; the framework has had a great many. An audit of
1715
+ `src/core/dashboard/` against today's `run.yml` and `waiting.ts` found seven words the files use
1716
+ and the page dropped. A page that drops a word does not look wrong — it looks finished, which is
1717
+ worse, and every one of these was measured on a rendered fixture before it was fixed.
1718
+ - **`prepared` and `running` rendered as "nothing".** `waitingFor` has had eight kinds since the
1719
+ 2026-08-29 audit; the WAITING ON column named five and fell through to `nothing — <status>` for
1720
+ the rest. So a host-attended run with a `--prepare` bundle on disk — the entire attended loop —
1721
+ said it was waiting on nothing while `isMovable` had it wearing `← next` on the same row. Every
1722
+ kind now prints `waiting.message`, which is the sentence the CLI already prints, and `prepared`
1723
+ joins gate/answer/failed as a card: it is a run waiting on a person.
1724
+ - **An `agent` gate was counted as a human one.** The execution-path eyebrow read
1725
+ `N human, M auto`, so a run opened `--gates what:agent,plan:agent,build:agent` reported as
1726
+ all-human — the exact opposite of what it was set up to do, in the one number that eyebrow
1727
+ exists to give. It now counts all three, the same arithmetic `renderGates` does.
1728
+ - **Nothing showed what an agent gate was signed over.** `run.yml` records the verdict, the
1729
+ sample, what resolved and what was refuted, and the run-relative path of the COMMITTED evidence
1730
+ note; the model dropped the whole block, so `agent by reviewer` and `human by alan` read as the
1731
+ same kind of fact. A human signature is a name and a person accountable for it. An agent's is a
1732
+ name and nothing, unless what it checked is beside it. Now `path[].gateEvidence`, printed under
1733
+ the signer — the path as text, never a link.
1734
+ - **`$0.00 spent` on a run whose turns a host session paid for.** `spentUsd` is metered dollars
1735
+ and the meter drew it as the whole story: the exact failure `unmeteredNote` exists to stop the
1736
+ CLI making, made in a progress bar. `attendedBy`, `unmeteredTasks` and `hostTokens` now ride the
1737
+ model, the card prints `attended: host` in the CLI's own words, and the two currencies are shown
1738
+ side by side and never added — there is no exchange rate between a metered dollar and a host token.
1739
+ - **`build.branch_model` (#57) and the epic branches were invisible** in a section headed
1740
+ *Plan & build*. A chained plan on one integration branch and independent per-epic branches drew
1741
+ the identical table. A null model is reported as unrecorded, never guessed at as `per-epic`.
1742
+ - **A `stale` stage looked finished.** A stage left behind by `tldrx reject --stage` wears
1743
+ `done` with its outputs still on disk, derived from a decision that has been withdrawn. It now
1744
+ wears a `stale` chip too.
1745
+ - **The page claimed it read `events.jsonl`.** It never has (measured: nothing in `model.ts` or
1746
+ `loadPhaseArtefacts` opens the ledger). That sentence is why a reader could not tell an empty
1747
+ ledger from an unread one, so the *How to use it* tab now says which files it reads, that the
1748
+ ledger is not among them, and which commands do read it. The Watchers tab, which existed
1749
+ precisely to be honest about a gap, printed an invented card shape whose field names matched
1750
+ none of the seven `Watcher` actually carries; it now prints the real one and names
1751
+ `tldrx watch list`.
1752
+ - **`review` was grey.** A `PLAN_STATUSES` value the page's tone function had never heard of, so
1753
+ a story in review landed in the same colour as one nobody had started.
1754
+ - **The test that should have caught all of this asserted the opposite.** `WAITING_KINDS` is now a
1755
+ value rather than a bare type union, and the chain fixture's coverage test names the three kinds
1756
+ it cannot hold instead of claiming it covers every kind — the claim that let `prepared` go
1757
+ unrendered for months. `modelVersion` stays at `3`: every model change here is an ADDITION, and
1758
+ the rule in `model.ts` is that only a removal or a change of meaning bumps it.
1759
+ - **What the same audit found and did NOT fix is #85**, because each of it is a decision rather
1760
+ than a patch: reading `events.jsonl` (operator notes, story attempts and reopens), reading
1761
+ `budget.yml` (per-phase ceilings, `on_exceed`, whether `next` is affordable), reading the
1762
+ watcher cards, and surfacing a preflight refusal. #86 is a separate bug the audit turned up in
1763
+ `run/waiting.ts` — a cancelled run is offered a retry — which both screens share.
1764
+
1765
+ - **The #80 guard's kind sweep could not tell a regex literal in CODE from one quoted in a
1766
+ COMMENT (#83).** The marker half of that guard has a discriminator and says why: a `[` inside a
1767
+ regex opens a character class, so code that matches the `[src:` marker must spell it `\[src`
1768
+ while prose writes `[src: …]` bare. The kind half had no equivalent, because prose quoting a
1769
+ regex LITERAL copies it character for character — there is no signature inside `/^F\d{3,6}$/`
1770
+ that a real production has and a quoted one lacks. #81 paid the tax: the doc comment explaining
1771
+ which two literals had just been DELETED tripped the sweep in the very file that had stopped
1772
+ classifying, and had to be reworded to name the shapes without writing them.
1773
+ - **The discriminator is the line's ROLE, not its characters.** A line that is comment prose —
1774
+ a whole-line `//`, a block opener, or one of its `*` continuation lines — is not a
1775
+ classification, and the sweep now reads the file with those lines removed.
1776
+ - **Line-local, holding no state, and that is the design rather than a shortcut.** #80's own
1777
+ first attempt reached for a lexer and was bitten twice: eleven offenders that were all doc
1778
+ comments, then five more that were template literals nested inside interpolations once
1779
+ comments and strings were stripped. This predicate never looks inside a string, so an
1780
+ unclosed `/**` in a prompt cannot unbalance it and blind the sweep to the code below —
1781
+ asserted, not assumed. What it cannot lex it keeps: a shape quoted in a trailing comment
1782
+ after code is still read as code, so this half still fails CLOSED.
1783
+ - **Measured in both directions on the real tree, not only on fixtures.** Re-injecting #81's
1784
+ exact doc comment into `validateFactsFile.ts` leaves the sweep green; injecting two real
1785
+ productions as CODE into the same file turns it red with `validateFactsFile.ts classifies
1786
+ answer, fact` — the identical message #81 saw for prose. The sweep is also no longer
1787
+ vacuous by assumption: a test pins the kinds the canonical grammar itself trips
1788
+ (`answer`, `cmd`, `fact`) and the six productions the deleted `core/map/srcToken.ts`
1789
+ carried, so a production that stops matching anything becomes visible rather than silent.
1790
+ - **What it removed, measured over the tree.** 28 files matched at least one production
1791
+ before, 20 after; the 8 that stopped matching were doc comments mentioning `→ exit` and
1792
+ nothing else — each one production away from #81's surprise red. No file lost a
1793
+ classification: the most kinds any file decides is still 3, and it is still `srcToken.ts`.
1794
+
1795
+ - **A handshake called in the wrong ORDER no longer fails the stage and the run (#82).** On the
1796
+ live run `260901-leaderboard-v2` (2026-09-02T00:36Z) the driver ran `tldrx next --commit
1797
+ --review` while no reviewer bundle was out. The framework said exactly the right thing — "no
1798
+ reviewer bundle is out — run `tldrx next --prepare --review` first" — and then emitted
1799
+ `stage.failed` twice and flipped the run to `status: failed`, so a mistyped command needed an
1800
+ explicit recovery. Nothing had been attempted: no spawn, no cent, no branch, no story moved.
1801
+ Same family as #78/#79 (form vs work), applied to state transitions instead of attempts.
1802
+ - **Five refusals are reclassified**, all of them the handshake asked for by the wrong end:
1803
+ `--commit --review` with no reviewer bundle out; `--commit` with no story `in_progress`;
1804
+ `--prepare --review` over a story whose developer half has not run; and either `--commit`
1805
+ before its `result.json` has been written. Each now exits `1`, names the command that fixes
1806
+ it, and leaves `run.yml` byte for byte as it was — no `stage.failed`, no status moved, no
1807
+ task recorded, no cent metered.
1808
+ - **This is the behaviour the framework already had one layer over.** A single-agent stage sent
1809
+ `--commit` before its `--prepare` has always returned exit `1` and touched nothing
1810
+ (`commitStage`). Build was the outlier because Build owns its own middle, so its refusals came
1811
+ back as `ok: false` and `runNext` could only read that as a failed stage. The fix gives Build
1812
+ the same door rather than inventing a second contract.
1813
+ - **`sequencing` is a second flag, not a widening of `refused`.** `refused` sends the stage back
1814
+ to `ready`, which is right for a precondition an operator must go and fix — a dirty repo, a
1815
+ red base tree — because the cycle cannot continue. A sequencing refusal is a cycle that is
1816
+ perfectly fine, held by the wrong end: the bundle is still out and sending the stage back to
1817
+ `ready` would throw away the state the next command needs. Honoured only when the outcome
1818
+ carries no tasks, no cost and no epic claim, so a refusal that spent something still records
1819
+ it; defaulting to "record it" is the direction a mistake here is recoverable in.
1820
+ - **The bug had a price, and it was the budget brake.** `runExecutor` skips the phase-budget
1821
+ gate exactly when a stage is already `running`, because a Build stage hands out one story per
1822
+ `--prepare`/`--commit` cycle. Failing the stage demoted it out of `running`, so the next
1823
+ `--prepare` was priced as a fresh stage start: on the live run that was a `budget.blocked`
1824
+ ten seconds after the refusal, $2.66 left against a re-charged $4.32 estimate, for money
1825
+ already partly spent. Reproduced in the fixture and pinned.
1826
+ - **`PendingError` carries a typed `kind`** — `absent` (the file was never written; a step of
1827
+ the handshake) vs `unreadable` (somebody wrote it and got it wrong; still a failure). Typed
1828
+ rather than matched on the message, for the reason #79 gave: a caller reading the words breaks
1829
+ the moment the message improves.
1830
+ - Genuine failures are untouched and pinned: a red Definition of Done still blocks its story, a
1831
+ reviewer that dies is still recorded as a failed check, and a plan that cannot be loaded is
1832
+ still `stage.failed` with a `failed` run. Two existing pins moved deliberately, both of them
1833
+ asserting the old exit `5` of a pure sequencing refusal.
1834
+
1835
+ - **`facts.yml` ids and citable ids were two spellings of one shape; now they are one constant
1836
+ (#81).** `src/core/facts/validateFactsFile.ts` defined its own `F` and `Q` id patterns, agreeing
1837
+ with `SRC_PATTERNS.fact` / `.answer` character for character, with nothing asserting that they
1838
+ must. Found while fixing #80 and filed rather than fixed there, because it is not #80's defect:
1839
+ the `[src: …]` token grammar has exactly one reader now, and what was duplicated here is the id
1840
+ SHAPE that grammar happens to share with the facts file, reached by a different reader answering
1841
+ a different question — which is why #80's kind sweep allowlisted the file rather than folding it in.
1842
+ - **They must agree, and the DIRECTION is what makes it load-bearing.** One string makes one
1843
+ trip: `formatFactId` mints `F102`, `validateFactsFile` admits it into `.tldrx/memory/facts.yml`,
1844
+ and `classifySrc` reads that same `F102` back out of a `[src: F102]` token for `knowledgeFile`
1845
+ to resolve against the store. So every id the facts file accepts must be citable, or a fact
1846
+ exists that cannot be cited and is invisible to every reader downstream of it. Nothing in that
1847
+ pipeline ever runs both readers on the same string, so the drift would have been silent. (The
1848
+ other direction is merely untidy: a citable id no facts file holds is caught later, as a
1849
+ citation that resolves to nothing.)
1850
+ - **Derived, not merely asserted.** `validateFactsFile.ts` imports `SRC_PATTERNS.fact` and
1851
+ `.answer` — the same publication surface `srcGrammarContract.ts` already generates the
1852
+ documented grammar from — so there is one spelling rather than two that match. The #80
1853
+ allowlist entry is deleted along with the need for it, and that sweep now covers the file like
1854
+ any other; an exemption that has stopped being needed is one that will some day cover something
1855
+ it was never granted for.
1856
+ - **A behavioural guard too, because sharing a constant only holds while nobody re-types a
1857
+ literal.** The kind sweep needs TWO productions in one file before it fires, so a single
1858
+ respelled `F` shape slips straight past it — measured by sabotage, not assumed.
1859
+ `test/map-citations.test.ts` now runs both readers over a table of ids spanning both digit
1860
+ boundaries and compares their verdicts; loosening either shape by one digit turns it red with
1861
+ `F12: facts.yml holds it but the grammar refuses it`.
1862
+ - **One drift had already happened, in prose.** The `source.q` refusal read `expected ^Q\d+$ or
1863
+ null` while the reader ran `^Q\d{1,6}$` — the shape's third spelling, telling an author a
1864
+ seven-digit question id was acceptable and then refusing it. Both refusal messages are now
1865
+ generated from the pattern they enforce, so neither can drift from its reader again.
1866
+ - **The docs site said 0.3.1, named a flag that does not exist, and told readers to hand-write
1867
+ a mandate the CLI now prints.** A cold read of every page against `tldrx <cmd> --help`, EN and
1868
+ ES. Three claims were false rather than merely stale: the version, printed on six pages, was two
1869
+ releases behind what `npm i -g tldr-experts` installs; `--max-budget-usd` was named as a `tldrx
1870
+ next` flag in both budget pages, but it is what tldrx passes down to `claude` and `tldrx next
1871
+ --max-budget-usd 3` exits `1` on an unknown flag; and `guides/driving` said "there is no keyword
1872
+ for the third part — the mandate is prose you write" three weeks after `tldrx drive
1873
+ --attended|--unattended` shipped to print exactly that, versioned with the package. Also
1874
+ corrected: the hero claimed a gate you own at the end of *each* stage, where `feature` ships two
1875
+ `auto` ones; the quickstart said "five role experts" one line under output reading `experts 6
1876
+ seeded`, and reported `$0.31 of $4.00` on a run opened at `$5.00` without saying the second
1877
+ figure is the stage's ceiling.
1878
+ - **The site covered none of the last release's commands, and the Spanish mirror had fallen a
1879
+ release behind the English one.** Added across both languages: `story reopen --for-fix`,
1880
+ `questions lint`, `retro --all --json`, `watch check --execute`, `update_check: off`, `seed
1881
+ answer`, exit `130`, `budget.yml`'s `ceiling_host_tokens` / `on_host_tokens_exceed` (the host
1882
+ economy was documented as unboundable), the integration-branch model for chained epics (#57),
1883
+ a FAQ entry for `tldrx update` and the version notice, and a link to `CONTRIBUTING.md`. The ES
1884
+ CLI reference was additionally missing `update`, `drive`, `retro --all` and `plan schema`
1885
+ outright. `reference/cli` no longer pins a version number that goes stale every release. The
1886
+ Spanish pages' code comments were half-translated; terminal output stays English, the docs
1887
+ author's own `#` comments do not.
1888
+
1889
+ - **`tldrx map --check` ran a SECOND, divergent `[src: …]` grammar; there is one grammar now
1890
+ (#80).** `src/core/map/srcToken.ts` was not a thin wrapper over the claim-sources reader — it was
1891
+ a parallel set of regexes, and they disagreed on five axes: its `file` pattern
1892
+ (`[^\s:]+`) refused a path containing a colon, its answer pattern (`^Q\d+$`) had no digit cap,
1893
+ its token pattern was **global** so a citation written mid-sentence counted as one, it stripped no
1894
+ trailing backtick or full stop before matching, and it had never heard of `aidlc:`. So a citation
1895
+ `claim-sources` accepted could be reported by `map --check` as a problem, and one it refused could
1896
+ be reported as fine — the "two readers of one question drift, and the looser one wins the argument
1897
+ at the wrong moment" hazard `core/text/handoff.ts` is written against. It also carried #77's
1898
+ defect on its own path, printing the symptoms `bullet has no [src: …] token` and
1899
+ `unparseable src token` and nothing else.
1900
+ - **The parser is deleted, not wrapped.** `checkCitations` calls `parseSrcToken`,
1901
+ `diagnoseSrcToken` and `describeSrcFailure` from `src/core/text/srcToken.ts`, so every `map
1902
+ --check` grammar failure gets the #77 treatment by construction: the rule id, the rule in its
1903
+ own words, the line as written, and a line that would have passed. `CitationProblem` gained a
1904
+ `rule` field, so a caller can tell "you wrote it wrong" from "it has drifted" without parsing
1905
+ prose. `srcToken(srcs)` — the BUILDER, which had no equivalent on the canonical side — moved
1906
+ across rather than went, and now joins on the same `SRC_SEPARATOR` the reader splits on.
1907
+ `isBullet` stayed behind in `checkCitations.ts`: it is about DOCUMENTS, and mixing the two is
1908
+ what the deleted file got wrong.
1909
+ - **The migration hazard was measured before the switch, not asserted after it.** Both grammars
1910
+ were run over every `.tldrx/map/**` document and init handoff in the two real workspaces
1911
+ available — **39 documents, 692 lines, 435 of them carrying a citation** — and they disagreed on
1912
+ **zero** lines: no multi-token line, no non-bullet citation, and not one token that failed to end
1913
+ its line. The axes where they *would* differ are pinned as a table in
1914
+ `test/map-citations.test.ts`. Unification is **stricter** on six shapes (a mid-line token, a
1915
+ `..` in a path, a 7-digit `Q`, a 4-digit exit code, a backwards line range, a line number of
1916
+ zero) and **looser** on five that the old reader wrongly refused (a path containing a colon, an
1917
+ `aidlc:…#Q<n>` src, a `cmd` whose command holds a backtick, an `absent:` path with a space, a
1918
+ token wrapped in backticks). An `aidlc:` citation used to be misread as repo `aidlc` and
1919
+ reported as an unknown repo; it is a kind now.
1920
+ - **A guard makes re-adding a copy go red** (#48's lesson: deleting one file does not stop a
1921
+ second). Two halves, both on the SHAPE rather than the file name — no file outside the canonical
1922
+ grammar and two documented non-parsers may hold a regex that matches the `[src:` marker, and no
1923
+ file outside it may classify two or more src KINDS. The guard is proven against the deleted
1924
+ file's own two regexes rather than assumed. It also turned up a real second copy of the fact and
1925
+ question id shapes in `validateFactsFile.ts`, filed as #81 rather than fixed here.
1926
+ - **`map --check`'s summary counts the two failures separately.** A citation that does not parse
1927
+ never reached the filesystem, so folding it into "N of M citations do not resolve" reported a
1928
+ denominator it was never in — and, when every problem was a grammar one, printed "3 of 0".
1929
+
1930
+ - **A `[src: …]` rejection now states the RULE it enforced, and the grammar is published where the
1931
+ writers read it (#77).** Run `260830-ordering-inventory` lost **three story attempts** to one
1932
+ message. Three review envelopes were refused with "no `[src: …]`" — the SYMPTOM — while all three
1933
+ carried a citation. The host guessed the grammar twice, got it wrong twice, and finally opened
1934
+ `dist/tldrx.js` to extract three rules that were written down nowhere a writer could read them:
1935
+ the token must END its line (`TRAILING_TOKEN_RE` is anchored), a `]` **inside** the token
1936
+ truncates the match (`[^\]]*`), and a `cmd` source needs the real `→`, never ASCII `->`.
1937
+ - **Every rejection on the path names its rule, quotes the line, and shows a corrected one.**
1938
+ `SRC_RULES` (`src/core/text/srcToken.ts`) is fifteen rules, each with the pattern that enforces
1939
+ it and a worked `bad`/`good` pair; `diagnoseSrcToken` maps a failure onto exactly one. The hook
1940
+ denies, the gate's `claim-sources` detail, `parseFixFindings`' `refuted` refusal and every
1941
+ `classifySrc` error now carry the id. The deny messages take the document text and quote the
1942
+ offending line back — naming `L14` and stopping there is what sent the host looking.
1943
+ - **The grammar is GENERATED and spliced into the prompt** (`renderSrcGrammarContract`), following
1944
+ the #35 precedent: kinds from `SRC_KINDS`, patterns from `SRC_PATTERNS` printed `.source` and
1945
+ all, rules and examples from `SRC_RULES`, the four-section rules from the same constants
1946
+ `handoff.ts` enforces. It reaches the writers through the check-contracts registry (every stage
1947
+ declaring `claim-sources` over a `.md` output), the Watch executor — which read `stage.md`
1948
+ raw and so was the one writer never given any contract — and the reviewer prompt, whose
1949
+ `refuted` verdict is held to this grammar and was never told what it is.
1950
+ - **The trap is behavioural, not textual.** `test/src-grammar.test.ts` pushes every documented
1951
+ `bad` back through `diagnoseSrcToken` and every `good` through `parseSrcToken`. Loosen a regex
1952
+ without updating its rule and the suite goes red; a doc that has stopped being true cannot ship
1953
+ quietly. It caught one bug on its first run: `RegExp.source` re-escapes non-ASCII, so
1954
+ `CMD_RE.source` spells the arrow as a `\u`-escape — the contract would have documented "use the
1955
+ real `→`" with the arrow itself written as an escape sequence. `readableSource` decodes it.
1956
+ - Not changed: what the parser ACCEPTS. Tolerating `->` is a product decision and stays open
1957
+ as #77's item 3.
1958
+ - **A grammar-rejected review envelope no longer consumes a story ATTEMPT (#78).** Measured on run
1959
+ `260830-ordering-inventory` (2026-09-01): stories S2, S3 and S5 each recorded
1960
+ `check: review · verdict: changes · attempt: 1` over a summary beginning *"I would sign this:
1961
+ every named acceptance criterion is met"*. Those were `fixlist` envelopes refused by the
1962
+ **claim-sources** check — a `refuted` finding whose `[src: …]` sat mid-line, where §2.8's
1963
+ end-anchored parser cannot see it — and each was charged to the story as a failure of its WORK.
1964
+ Three of the run's attempts went on formatting. A malformed envelope is a fault in the reviewer's
1965
+ *report*; conflating the instrument with the result is what this fixes.
1966
+ - **The framework asks again, for free.** A refusal the claim-sources check raised re-prompts the
1967
+ same reviewer for a corrected envelope, carrying what was refused verbatim under
1968
+ `## Your previous envelope was REFUSED`. **Bounded at two** per envelope round (owner decision,
1969
+ 2026-09-01): the third refusal is recorded as the ordinary `changes` and costs the attempt, so a
1970
+ reviewer that cannot write the grammar at all still settles instead of looping free. The bound
1971
+ resets when a verdict is finally counted — it is per envelope round, not per story.
1972
+ - **Both doors, one rule.** A spawned reviewer re-prompts itself in-process;
1973
+ `tldrx next --commit --review` leaves the bundle out with the refusal spliced into its
1974
+ `prompt.md`, bins the refused `result.json` and settles nothing. Attempt accounting must not
1975
+ depend on which door a verdict came through, so both go through the same predicate.
1976
+ - **Auditable, because it is bookkeeping.** Each free round appends one `story.review_retried`
1977
+ (§2.9) carrying the story, the attempt it did **not** spend, which retry it was, the bound, and
1978
+ the refusal. `readReviewLedger` counts them, which is how the bound survives a fresh
1979
+ `tldrx next` and the one-envelope-per-process host handshake alike. Each re-prompt is a real
1980
+ metered turn and gets its own task row: it costs the story no attempt, never no money.
1981
+ - **Scope guard, pinned by test.** Only the claim-sources grammar — *widened to every
1982
+ envelope-FORMAT refusal by #79 below, in the same release.* A verdict's CONTENT and a red DoD
1983
+ keep exactly the cost they had.
1984
+ - **The message is #77's, inherited rather than copied.** The re-prompt carries the refusals
1985
+ verbatim and points at the `Citation grammar` section #77 splices into the
1986
+ same prompt, so the reviewer is told which rule it broke, on which line, with a corrected
1987
+ example — and there is no second copy of the grammar to keep in step. The classifier
1988
+ is a typed INDEX over `problems`, not a second list, precisely so #78 could not end up
1989
+ string-matching the text #77 was rewriting.
1990
+
1991
+ - **Every envelope-FORMAT refusal gets that same free round, not just the citation (#79).** #78 drew
1992
+ its scope at the claim-sources check because that is what the evidence named, and filed the rest.
1993
+ But `parseFixFindings` refuses an envelope for five other shapes — a `fixlist` that is missing,
1994
+ not an array or empty; a row that is not an object; a row with no `finding` text; a row with no
1995
+ valid `disposition` — and `parseReview` refuses a sixth, a verdict WORD outside the enum (#36).
1996
+ Every one of them is a fault in how the reviewer wrote its *report*, exactly the argument #78 made,
1997
+ and every one of them still cost the story an attempt. Owner decision (2026-09-01, on the issue):
1998
+ all of it, **one mental model — FORM never costs an attempt, CONTENT/WORK always does.**
1999
+ - **Nothing about the mechanism changed.** Same bound (two per envelope round, the third is the
2000
+ ordinary `changes`), same counter, same `story.review_retried` event, same two doors, same
2001
+ per-turn metering. #79 widened *what earns a correction* and touched nothing else.
2002
+ - **The verdict WORD (#36) is now free too, and #36's message is unchanged.** A reviewer that
2003
+ writes `sign` — the gate vocabulary — said nothing wrong about the diff; it reached for a word
2004
+ the story enum does not have. It is told so, by name, and asked again. `Review.formatProblems`
2005
+ is the union of the fix-list index and that verdict fault, because it is also what the corrected
2006
+ envelope's prompt is rendered from: a refusal missing from it is one the reviewer is never told
2007
+ about.
2008
+ - **The guard is the INDEX, which is what survives the next widening.** `ParsedFixlist.format`
2009
+ stays a typed subset of `problems`, built one push site at a time through a single
2010
+ `refuseFormat` helper — never a second list of strings, and never "everything `parseFixFindings`
2011
+ said". The free round is granted only when the index claims **every** reason the envelope was
2012
+ refused, so a refusal about the WORK added later costs the attempt until somebody deliberately
2013
+ indexes it as form. Defaulting to *costs* is the direction a mistake is recoverable in.
2014
+ - **A non-empty `findings[]` is deliberately NOT the content signal**, though it is the obvious
2015
+ candidate. Measured across the nine `aparece-v2` runs (2026-09-01): all 25 recorded review logs
2016
+ carry a non-empty `findings[]` and all 25 are `approve` — the one verdict whose own prompt line
2017
+ says *"Empty on `approve`"*. Reviewers use it as a narrative evidence log whatever the verdict,
2018
+ so gating on it would have made the free round almost never fire and quietly narrowed #78 as
2019
+ well. A judgement about the work is caught where it is actually stated: a declared `changes`
2020
+ raises no format refusal at all, so it costs its attempt.
2021
+ - **Nine tests red first**, each newly-free class proven to re-prompt without spending an attempt
2022
+ and to record the event, plus the bound re-proven on a shape refusal. #78's CONTENT and DoD pins
2023
+ are unchanged and still green.
2024
+
2025
+ - **`merge-wave.sh` no longer leaves a conflicted tree behind, wedging every queued sibling (#76).**
2026
+ On a merge conflict the script exited `2` **without** `git merge --abort`, and the `EXIT` trap
2027
+ then released the lock. The conflicted index survived that handover, so the next queued
2028
+ invocation acquired the lock, failed its dirty-tree guard and exited `1` `FAIL dirty tree` having
2029
+ merged nothing — and so did every one after it, until a human ran the abort by hand. Observed
2030
+ live 2026-09-01 by two agents: a sibling's abandoned merge left `UU CHANGELOG.md` plus 14 staged
2031
+ paths in the shared checkout, and cluster L's first merge-wave returned `1` with nothing merged.
2032
+ Under the concurrent multi-cluster pattern, one conflict wedged every other cluster.
2033
+ - **Collect, then abort** — the order `mergeNoFf` already uses one directory over
2034
+ (`src/core/build/git.ts:314-326`). The agent still learns exactly which files conflicted and
2035
+ still has to rebase and retry; the checkout it hands back is the one it was given. This is the
2036
+ same class of hazard the lock was written for: state from one invocation leaking into the next.
2037
+ - **The refusal now names what is dirty.** `FAIL dirty tree` alone accused the caller of leaving
2038
+ junk in their own checkout when, inside the lock, the likeliest cause is another run's residue
2039
+ in the shared one. It now says so and lists the paths.
2040
+ - Proved by a repro that runs the real script against a real conflicting merge and asserts
2041
+ `git status --porcelain` is empty afterwards, `MERGE_HEAD` is gone, `HEAD` has not moved, the
2042
+ lock is released, and a second invocation merges instead of being refused.
2043
+
2044
+ - **The built-CLI dashboard test no longer gates a stale `dist/` (#73).** `beforeAll` read
2045
+ `if (existsSync(DIST)) return`, so the tests ran against whatever `dist/tldrx.js` happened to be
2046
+ lying around — locally, a build from before the working-tree changes the run was checking.
2047
+ Measured 2026-09-01 while fixing #60: the guard served a binary built at 14:43, and the model
2048
+ version assertion read `Expected: 3, Received: 2`. That is the lucky direction; the same staleness
2049
+ hiding a regression is silent, and `bun run build` is a separate later step in both
2050
+ `scripts/merge-wave.sh` and CI. CI was safe only by accident — a fresh checkout has no `dist/` —
2051
+ which means the guard only ever applied where it did harm.
2052
+ - **Always rebuild, and the number is the reason.** `bun scripts/build.ts` costs 199 ms cold and
2053
+ 55–59 ms warm on the reference machine (3 runs, 2026-09-01) against a ~420 s suite: under 0.05%.
2054
+ A stamp of `src/` would cost more to keep honest than it saves, and `test/build.test.ts:78`
2055
+ already built unconditionally — this file was the exception.
2056
+ - A new assertion pins the property rather than the mechanism: `dist/tldrx.js` may not predate the
2057
+ newest file under `src/`, `scripts/build.ts` or `package.json`.
2058
+
2059
+ - **`tldrx drive` fills the mandate's `<run>` in (#75).** The mandate's every command read
2060
+ `tldrx next --prepare <run>`, and the header told the reader to find-replace — 7 occurrences
2061
+ unattended, 5 attended (measured; the issue estimated ~8), by hand, at the exact moment somebody
2062
+ is trying to start a run. One occurrence missed sends a session at the wrong run.
2063
+ - **An id, or the one open run.** `tldrx drive --unattended <run>` or `--run <id>` (the positional
2064
+ wins, `ship`'s order) substitutes **textually and never validates** — an id naming no run is the
2065
+ operator's typo to notice, and the command stays the one thing in the CLI that runs anywhere.
2066
+ With no id, the ONE open run of the current workspace is used.
2067
+ - **It refuses to guess between two.** Where `RunStore.resolve` would call it ambiguous, drive
2068
+ declines to substitute, leaves `<run>` standing and names the ids on stderr — a mandate silently
2069
+ aimed at the wrong run is the bug being fixed, not a smaller version of it. No workspace, no
2070
+ runs, an unreadable `tldrx-work/`: all keep the placeholder and still exit `0`.
2071
+
2072
+ - **`tldrx status` no longer calls a RUNNING run "cannot start yet" (#60).** Verbatim from
2073
+ aparece-v2, 2026-09-01: `run 260830-ordering-inventory (…) cannot start yet — it was proposed to
2074
+ follow money-and-payments` / `at 04-build / build · run status running · waiting: prepared` /
2075
+ `blocked by money-and-payments — it is pending`. The run was building, with S1 verified minutes
2076
+ earlier. `triage.depends_on` is an order a split PROPOSED before either run existed, and it was
2077
+ out-ranking what the run was observably doing.
2078
+ - **Observed state outranks proposed order.** One rule, in the resolver both screens read
2079
+ (`src/core/run/dependencies.ts`): a run that has left `pending` has started, and a proposal
2080
+ cannot un-start it. `hasStarted` is the one definition — `pending` is the only status a run that
2081
+ has never run a stage can wear, because runs are created with every stage `pending` and every
2082
+ path to any other value goes through a stage that was `running`.
2083
+ - **The proposal becomes a footnote, not the headline.** A started run renders its own cursor and
2084
+ waiting kind, gains back the command it was denied, and carries
2085
+ `proposed to follow <run> — started anyway` as a secondary line. `blocked by` is now reserved
2086
+ for a run that really cannot move — one that has not started. A run that has NOT started is
2087
+ unchanged: same words, same withheld command.
2088
+ - **It gets the `← next` slot back.** The ordering hint had demoted the only run with work in
2089
+ flight and pointed the owner at the sibling that had not begun. `runnable` is now
2090
+ `movable && (started || nothing outstanding)`.
2091
+ - **`prepared` and `running` get their own summary lines.** Both used to fall through to
2092
+ "is blocked at <phase>/<stage>" — the same wrong word, one layer down.
2093
+ - **The dashboard was making the identical claim** about the same shape and is fixed with it:
2094
+ fixture run `charlie` is `awaiting_gate` behind a `pending` sibling, and the page said
2095
+ "blocked by bravo" while suppressing the gate alert for a signature a person could give right
2096
+ then. `DASHBOARD_MODEL_VERSION` → **3**: `blockedBy` is unchanged and still records the
2097
+ proposal, the new `runs[].started` says whether it still holds anything back, and `runnable`
2098
+ reads `true` for that one shape where it read `false`.
2099
+
2100
+ - **A cost/token declaration on a story commit now attaches to the build task (#68).** Measured on
2101
+ two live runs: the leaderboard host declared $2.25 on a story commit and `tldrx cost` kept
2102
+ 04-build at $0.00 — "the declaration didn't attach to the build task the way it did for
2103
+ what/how/plan" — and ordering 260830 showed the same shape. The run's recorded build spend was a
2104
+ floor, budget arithmetic ran blind on the most expensive phase, and ~8M host tokens were invisible.
2105
+ - **The seam, not the accounting.** `ExecutorContext` never carried `--cost-usd`/`--tokens`, so
2106
+ Build's `commit()` could only read the envelope's own `cost_usd` and wrote
2107
+ `round2(result.cost_usd ?? 0)` — a METERED `$0.00`. It now resolves
2108
+ `options.costUsd ?? result.cost_usd`, which is exactly what `commitStage` does for what/how/plan,
2109
+ and the declared tokens ride onto the task row and the `agent.result` payload the cost report
2110
+ reads. The reviewer half (`--commit --review`) takes the same precedence.
2111
+ - **Nothing declared is UNMETERED, not $0.00.** `cost_usd: null` + `metered: false`, the spelling
2112
+ every other host turn already gets, because `$0.00` is a measurement and a false one.
2113
+ - **Backward-safe.** A declaration is never allowed to overwrite a measurement — an envelope that
2114
+ reports its own cost still wins over nothing declared — and no recorded zero is rewritten. Only
2115
+ new declarations attach.
2116
+
2117
+ - **`onStderrLine` on the runtime seam, so progress prints before the verdict it produced (#67).**
2118
+ `SpawnOptions` had `onStdoutLine` and nothing for the other stream, so stderr — where every tldrx
2119
+ UI writes its progress — could only be handed back as one string at exit. `tldrx learn` printed
2120
+ `01-what/what done — $0.31 of $4.00` and only THEN the `[00:00] writing …` lines that produced it.
2121
+ - Implemented in **both** `bunRuntime.ts` and `nodeRuntime.ts`, with the same contract
2122
+ `onStdoutLine` already has: the full text still accumulates in `SpawnResult.stderr`, a trailing
2123
+ partial line is delivered at close, and omitting the callback leaves the buffered path
2124
+ byte-for-byte as it was. Node gets its own `LineSplitter` per stream — one shared buffer would
2125
+ splice a half-written stdout line onto the front of a stderr line.
2126
+ - **The learn workaround is deleted**, per the instructions it carried since phase 1:
2127
+ `realStepRunner` passes the callback and `playChapter` no longer writes `result.stderr` after the
2128
+ step. Measured by playing chapter 2 by hand on both checkouts: the summary was at line 107 with
2129
+ its progress lines at 109-124, and is now at 123 with them at 105-121.
2130
+ - The tests turn on TIMING, not content: a buffered implementation could hand the same lines to the
2131
+ same callback by splitting at exit, so the line is required to arrive while the spawn's promise
2132
+ is still pending.
2133
+
2134
+ - **`tldrx plan` has a section in the CLI reference (#55).** `docs/guide/08-cli-reference.md` had no
2135
+ `## tldrx plan` heading at all, so `sync-dod` — the one mechanical repair for stories whose dod
2136
+ block a `workspace.yml` edit orphaned — was undocumented. The section states the four per-line
2137
+ outcomes, the git-history ancestry, the `.md.bak`, and that it runs no agent and moves no cursor.
2138
+ The #54 docs-coverage test is widened from `run` to `run` and `plan`, and now also asserts the
2139
+ heading rather than a passing mention. It is still a scoped list rather than a generalisation over
2140
+ every command: `hook`'s seven scripts are a deliberate spelling, and `note` and `ship` have the
2141
+ same gap #55 was about (filed separately).
2142
+
2143
+ - **`tldrx update`, and a one-line notice when a newer version exists (#62, owner decision
2144
+ 2026-09-01: on by default, with an opt-out).** The owner installed 0.4.0 on a second machine and
2145
+ found there was no way to ask the tool to update itself, and no way to be told a newer one existed.
2146
+ - **`tldrx update` is `npm i -g tldr-experts@latest`, run for you**, plus the part a wrapper does
2147
+ not give you: the CHANGELOG between the version you had and the version you now have.
2148
+ - **The new version is READ BACK from what npm installed** —
2149
+ `$(npm root -g)/tldr-experts/package.json`, with the delta taken from the `CHANGELOG.md` beside
2150
+ it. The process printing that line is the OLD build and cannot know what the new one is; when the
2151
+ read-back fails it says so and prints no changelog rather than inventing one.
2152
+ - **The notice never touches the network on the hot path.** A command reads
2153
+ `~/.tldrx/version-check.json` and nothing else. The registry call happens in a DETACHED child
2154
+ (`stdio: "ignore"`, `unref()`) spawned after the output is written, and its answer is for the
2155
+ NEXT invocation. Cached for 24 h; silent on any network failure, on a body that is not the JSON
2156
+ it asked for, and on a home directory it cannot write.
2157
+ - **Never in `--json`, never during a hook, never off a terminal.** `tldrx hook` and
2158
+ `tldrx statusline` are suppressed by name (spec §0: a hook is deterministic), `--json` anywhere
2159
+ in argv is suppressed, a non-TTY stdout is suppressed, and so is CI. The line itself goes to
2160
+ stderr, so no command's stdout changes shape.
2161
+ - One line, in the issue's own wording: `tldr-experts 0.5.0 available (you have 0.4.0) — tldrx
2162
+ update`. Asserted as an exact string, because "roughly this sentence" is how one line becomes
2163
+ three.
2164
+ - **Opt out** with `TLDRX_UPDATE_CHECK=off` for a shell (spelled like `TLDRX_UI` and
2165
+ `TLDRX_CLAUDE_BIN`; `0`, `false`, `no` and `never` also work), or `update_check: off` in
2166
+ `~/.tldrx/config.yml` for the machine. A config file that does not parse is not an opt-out and
2167
+ not a crash.
2168
+
2169
+ - **`budget.yml` no longer adds host tokens to dollars (#61, owner decision 2026-09-01).**
2170
+ `validateRunBudget` summed EVERY phase ceiling and compared the total to `ceiling_usd` — but since
2171
+ the `economy:` label landed, a phase priced in `host-tokens` carries a host-session token allowance
2172
+ and not money. A realistic allowance therefore made a valid file invalid
2173
+ (`phase ceilings sum to 200018 > ceiling_usd 25`), and it did so in the one check that fails
2174
+ **closed**: `RunStore.open` threw, and the budget-gate hook then denied every spawn on the run.
2175
+ - **Separate ceilings, per economy.** `ceiling_usd` stays dollars-only and a new optional
2176
+ `ceiling_host_tokens` — at the run level and per phase — is the host-token allowance. The two are
2177
+ never added and never converted: there is no exchange rate here, and inventing one would be a
2178
+ guess about a price, which is the whole reason the label exists.
2179
+ - **The sum runs once per economy.** Σ the `metered-usd` phases against `ceiling_usd`; Σ the
2180
+ `host-tokens` phases against `ceiling_host_tokens`. The dollar half is byte-identical arithmetic
2181
+ with the same refusal in the same words.
2182
+ - **Additive, and absence changes nothing.** With no `ceiling_host_tokens` declared, the token sum
2183
+ has nothing to compare against and is not checked — deliberately the lax side, since the only
2184
+ other number on the run is dollars. The compat bar is the live `260830-ordering-inventory`
2185
+ budget.yml, mid-Build in another workspace while this was written and asserted verbatim in
2186
+ `test/economy.test.ts`: it validates, its phase sum of exactly 62.00 against a 62.00 ceiling still
2187
+ passes, one cent more is still refused in the same words, and every phase still reads
2188
+ `metered-usd` with no token ceiling.
2189
+ - `hostTokenCeiling` (f353d8d, #22b) prefers the new field and **still falls back to `ceiling_usd`**,
2190
+ because the files written before it existed put the allowance in that one unlabelled scalar and
2191
+ they are still on disk. The emitter round-trips both, so `budget raise` cannot erase a token
2192
+ ceiling it rewrites past.
2193
+ - The workaround the issue documented is gone: `test/hooks.test.ts` and `test/economy.test.ts` no
2194
+ longer raise the run ceiling to `60000`/`100000` — dollar figures that meant nothing — to let a
2195
+ token phase validate.
2196
+ - **`RunStore.ceilingsToWrite` now carries a phase's `economy` and `ceiling_host_tokens` from disk**
2197
+ beside its `ceiling_usd`. That seam re-reads CEILINGS before every save so a raise landing
2198
+ mid-stage is not clobbered by the copy the process opened with — and a phase's ceiling is three
2199
+ fields, not one. Preserving the number without the label would have been the worse half of both:
2200
+ a token allowance kept on a phase this reader had been told is priced in dollars, where
2201
+ `hostTokenCeiling` can no longer see it. For a file with no labels and no token ceilings — every
2202
+ budget.yml on disk today, the live one included — all three are identical on both sides and the
2203
+ save writes exactly what it always wrote.
2204
+
2205
+ ### Removed
2206
+
2207
+ - **`templates/story.md` and `templates/epic.md` are deleted (#48, owner decision 2026-09-01,
2208
+ option (a)).** They stated the Plan front-matter schema, shipped in the npm package, and
2209
+ `grep -rn 'story\.md' src/` found nothing that read either one. Since 3ae0ce9 the live copy is
2210
+ generated: `src/core/plan/schemaContract.ts` builds the story, the epic and `waves.yml` from
2211
+ `STORY_KEYS` / `EPIC_KEYS` / `PLAN_STATUSES` / the `MAX_*` constants and splices them into the Plan
2212
+ prompt. The drift guard 7ac298c added held the two files to that contract; deleting them removes
2213
+ the second copy instead of maintaining it.
2214
+ - **The consumers were tests, and they now generate.** `test/plan.test.ts` and
2215
+ `test/plan-schema-contract.test.ts` were the only readers; both take the story and the epic from
2216
+ `planContractExamples()`. Nothing in `src/`, `stages/`, `workflows/`, `plugin/` or `docs/` read
2217
+ either file, and `run new` never copied them.
2218
+ - **`templates` stays in `package.json` → `files`.** The directory still ships eleven templates
2219
+ that ARE read at runtime — `templates/expert.md` and `templates/experts/<role>.md` are read by
2220
+ `createExpert.ts` and `roleExperts.ts` in an installed package — so removing the entry would
2221
+ break `tldrx expert create` to delete two files that no longer exist.
2222
+ - **The drift guard changed meaning and kept its teeth.** It now asserts the GENERATED story and
2223
+ epic validate through `validateStoryFile` / `validateEpicFile` with keys equal to `STORY_KEYS` /
2224
+ `EPIC_KEYS` in order, that neither file is back on disk, and that no OTHER shipped template has
2225
+ grown the same front matter under a new name. Proven, not assumed: setting the example's
2226
+ `status:` to `wip` turns 4 tests red, and renaming `test_plan` in the generator turns 6 red.
2227
+
3
2228
  ## 0.4.0 — 2026-09-01
4
2229
 
5
2230
  ### Changed