tldr-experts 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1957 -0
- package/README.md +99 -12
- package/dist/hooks/answer-capture.js +21 -12
- package/dist/hooks/budget-gate.js +95 -11
- package/dist/hooks/{chunk-0bt6yb2q.js → chunk-39zh2e44.js} +10 -1
- package/dist/hooks/chunk-4cp363kv.js +1766 -0
- package/dist/hooks/{chunk-t8tdv11p.js → chunk-7y2dq0pj.js} +1 -1
- package/dist/hooks/chunk-9zsqxr6y.js +213 -0
- package/dist/hooks/{chunk-a8p2rc94.js → chunk-b8kxzna2.js} +1 -1
- package/dist/hooks/{chunk-j234zf0t.js → chunk-c6t5nx0r.js} +37 -166
- package/dist/hooks/{chunk-g395gk7e.js → chunk-m3mewgnw.js} +36 -17
- package/dist/hooks/chunk-phmdk72a.js +164 -0
- package/dist/hooks/chunk-rpcxsqh3.js +199 -0
- package/dist/hooks/{chunk-azctppjh.js → chunk-rz541e2b.js} +7 -1
- package/dist/hooks/{chunk-y0jdr3et.js → chunk-s1c5h7yx.js} +71 -29
- package/dist/hooks/{chunk-p274ckxv.js → chunk-sq44k6g2.js} +155 -12
- package/dist/hooks/{chunk-x98qs959.js → chunk-t1ywrfr4.js} +35 -42
- package/dist/hooks/{chunk-1zwcxd3f.js → chunk-tzzwddct.js} +1 -1
- package/dist/hooks/claim-sources.js +31 -23
- package/dist/hooks/dod-gate.js +12 -37
- package/dist/hooks/no-reask.js +9 -9
- package/dist/hooks/session-start.js +176 -422
- package/dist/hooks/statusline.js +14 -11
- package/dist/tldrx.js +22798 -12309
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/skills/tldrx/SKILL.md +16 -3
- package/stages/build/stage.md +5 -0
- package/stages/build/stage.yml +2 -1
- package/stages/plan/stage.md +11 -0
- package/templates/budget.yml +5 -0
- package/templates/epic.md +5 -3
- package/dist/hooks/chunk-ae6bkfs5.js +0 -0
- package/dist/hooks/chunk-kw4tffzf.js +0 -139
- package/dist/hooks/chunk-sdjnnmzz.js +0 -497
- package/dist/hooks/chunk-t56k6146.js +0 -14
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,1962 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 — 2026-09-01
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- **Dependent epics share ONE integration branch (#57, owner decision 2026-09-01, option (a)).**
|
|
8
|
+
One-branch-per-epic assumes the epics are independent. Run `260829-scoring-leaderboard` planned
|
|
9
|
+
E2 (the API) with E3 and E4 (mobile) consuming it, and a downstream story's base — cut from its
|
|
10
|
+
own epic branch, itself cut from `main` — could not see the upstream epic's merged work. It broke
|
|
11
|
+
twice, and both times the host fast-forwarded the EPIC branches by hand: cross-epic surgery done
|
|
12
|
+
with a feature built for stale STORY bases (design §F.2), which collapses branches the owner may
|
|
13
|
+
have meant to merge separately. A run whose epics form a dependency chain now cuts a single
|
|
14
|
+
integration branch and the epics become labels.
|
|
15
|
+
- **Detected at PLAN time, from what the plan already says.** A story whose `depends_on` names a
|
|
16
|
+
story in another epic IS the chain. `validatePlan` reports the cross-epic edges it read
|
|
17
|
+
(`PlanReport.epicChain`, deduplicated per epic pair), and the `plan` gate check states the branch
|
|
18
|
+
model in its passing detail — `epics form a chain (E3→E2, E4→E2) → single integration branch
|
|
19
|
+
\`epic/<run-id>\`` or `independent epics → one branch each`. The owner never discovers it
|
|
20
|
+
mid-Build, which is the half of #57 that was not about branches at all.
|
|
21
|
+
- **`epic/<run-id>`, not a new namespace.** `EPIC_BRANCH_RE`, `watch`'s feature-slug extraction,
|
|
22
|
+
`ship`, `boundary` and the `--reuse-epic` guard are all keyed on the `epic/` prefix; an
|
|
23
|
+
`integration/…` branch would have changed every one of them to buy a word. The run id IS in the
|
|
24
|
+
name — unlike an ordinary epic branch, which is deliberately unscoped because an epic is the unit
|
|
25
|
+
a team merges — because an integration branch belongs to one run by definition.
|
|
26
|
+
- **One run-scoped epic worktree**, `_epic-<run>-integration`, because git will not check one branch
|
|
27
|
+
out in two worktrees. It is picked up by the `_epic-<run>-` prefix that the §2.8 src resolver and
|
|
28
|
+
the run-close cleanup (#16) already enumerate, so the lifetime decision from that change holds.
|
|
29
|
+
- **Backward-safe by an ABSENT key.** The Build executor records what it used in `run.yml`
|
|
30
|
+
(`build.branch_model: per-epic | integration`, additive and optional). A run.yml that names
|
|
31
|
+
branches and no model predates the key — the three closed runs, and any run mid-flight — and stays
|
|
32
|
+
`per-epic`, so it resumes on the branches it already cut rather than being re-pointed at one that
|
|
33
|
+
was never cut. A model, once written, is never rewritten.
|
|
34
|
+
- **Nothing else moved.** The dirty-tree refusal, the foreign-epic refusal and `--reuse-epic`, the
|
|
35
|
+
gated HEAD, `git merge --no-ff` into the epic worktree, and the story-base fast-forward all behave
|
|
36
|
+
exactly as before; only WHICH branch they name changes. `tldrx ship` needed no change: a chained
|
|
37
|
+
run claims one branch, so it opens one PR with no `--branch`, and an unchained multi-epic run still
|
|
38
|
+
asks which.
|
|
39
|
+
- The acceptance test is the leaderboard shape, passing: S1 in E1 writes a file, S2 in E2 depends on
|
|
40
|
+
it, and S2's worktree holds that file with **no `story.base_fastforwarded` event** — the base was
|
|
41
|
+
right when the developer was dispatched, not repaired afterwards. Red before the change (`Expected:
|
|
42
|
+
true, Received: false`).
|
|
43
|
+
|
|
44
|
+
- **An epic worktree now lives for the RUN's lifetime, not the Build stage's (#16, owner decision
|
|
45
|
+
2026-09-01, option (a)).** The shipped half of #16 made a `file` src resolve against
|
|
46
|
+
`.tldrx/worktrees/<repo>/_epic-<run>-<epic>` before the working tree — and then `BuildSession.finish()`
|
|
47
|
+
removed that directory before the Build handoff was even written, so unless the operator had typed
|
|
48
|
+
`--keep-worktrees` a later Watch stage had nothing to resolve against and the fix bit only under a flag.
|
|
49
|
+
Cleanup moves to run CLOSE, which is what the checkout actually belongs to.
|
|
50
|
+
- **Watch citing epic-only code works by default.** Proved end to end rather than by construction:
|
|
51
|
+
`test/build-executor.test.ts` runs the real pipeline, then resolves a `[src: app:s1.txt:1]` against a
|
|
52
|
+
file the test first shows is committed on `epic/e1` and absent from the working tree.
|
|
53
|
+
- **Every close path takes them**, because a run does not only close one way: `tldrx next` closing the
|
|
54
|
+
last stage, `tldrx approve` signing the last gate, and `tldrx run cancel`. Without the last two the
|
|
55
|
+
change would have traded one leak for another — a cancelled run's checkouts used to be gone already,
|
|
56
|
+
because Build removed them on the way past.
|
|
57
|
+
- **`--keep-worktrees` keeps its meaning, one scope wider**: survive even the run close. It is
|
|
58
|
+
remembered on the run as `keep_worktrees:` (additive and optional; absent means clean up, which is
|
|
59
|
+
what every existing run.yml meant) because the flag is typed on the `tldrx next` that BUILDS and the
|
|
60
|
+
run is usually closed by a different command in a different process.
|
|
61
|
+
- Story worktrees are untouched: still removed the moment a story reaches `done` or `blocked`.
|
|
62
|
+
|
|
63
|
+
- **The budget gate's three open policy questions are answered (#22, owner decision 2026-09-01).**
|
|
64
|
+
bb6204b wired the DATA — both economies, `attended_by`, `runSpend` — and deliberately changed no
|
|
65
|
+
verdict. These are the verdicts.
|
|
66
|
+
- **(a) An `attended_by: host` run is INFORMED, never DENIED, on metered dollars.** `tldrx next` on
|
|
67
|
+
such a run spawns nothing, so the estimate the gate was refusing against is spend that provably will
|
|
68
|
+
not happen. Both the PreToolUse hook and `tldrx next`'s own brake now say every number they would
|
|
69
|
+
have refused with, plus both economies, and allow. The event is `budget.warned`, not `budget.blocked`:
|
|
70
|
+
nothing was blocked, and recording a block that did not happen is the exact failure #22 was filed
|
|
71
|
+
about.
|
|
72
|
+
- **(b) A `host-tokens` ceiling is soft-enforced.** Under that economy the ceiling NUMBER is a
|
|
73
|
+
host-session token allowance, so accumulated declared `tokens:` against it is the one comparison in
|
|
74
|
+
the gate whose two sides share a unit. Crossing it WARNS and still allows. It stops only under the
|
|
75
|
+
explicit opt-in **`on_host_tokens_exceed: block`** in `budget.yml` — an enum beside `on_exceed`,
|
|
76
|
+
defaulting to `warn`, so every file written before the key existed keeps the behaviour it had. The
|
|
77
|
+
refusal never offers `tldrx budget raise`, which moves dollars and would send the operator at the
|
|
78
|
+
wrong number. (a) beats (b): the opt-in still never denies an attended run.
|
|
79
|
+
- **(c) `remainingWork` zeroes the developer share on an attended run**, mirroring `economy:
|
|
80
|
+
host-tokens`, because it is the same fact — the host session pays for those turns. Attendedness and
|
|
81
|
+
the phase economy were independent, so an attended run on a `metered-usd` phase still counted
|
|
82
|
+
developer turns against money this framework will never spend, on the brake and in `run estimate`
|
|
83
|
+
alike. Reviewer floors are untouched in both cases.
|
|
84
|
+
|
|
85
|
+
### Added
|
|
86
|
+
|
|
87
|
+
- **The documentation site speaks Spanish (`docs-site/es/`, phase 2).** All twelve English pages are
|
|
88
|
+
now mirrored under `/es/` at the same paths and translated into es-MX developer Spanish, and
|
|
89
|
+
`locales.es.themeConfig` carries the Spanish sidebar, nav, edit link and page chrome (outline,
|
|
90
|
+
prev/next, search modal, footer) rather than English chrome around Spanish prose. The placeholder
|
|
91
|
+
that phase 1 left at `/es/` is replaced by the translated landing page.
|
|
92
|
+
- **tldrx's own vocabulary stays in English where it is an identifier.** The stage names
|
|
93
|
+
(`What → How → Plan → Build → Watch`), `run`, `story`, `DoD`, `scope`, `handoff` and `workspace`
|
|
94
|
+
are the things you type or the files on disk, so they are not translated; each is glossed once in
|
|
95
|
+
Spanish where it first appears. `gate` is the one exception — it renders as **compuerta**, because
|
|
96
|
+
the phase-1 placeholder had already shipped that word to the live site.
|
|
97
|
+
- **Code blocks and command output are verbatim English** — a translated transcript would be a
|
|
98
|
+
transcript of a command nobody ran. The narration around them is translated.
|
|
99
|
+
- **Anchors were verified against the rendered HTML, not assumed**, because dead-link checking does
|
|
100
|
+
not see them. That mattered here: VitePress's slugifier strips accents but *keeps* `¿`, so
|
|
101
|
+
`## ¿Cómo lo detengo?` becomes `id="¿como-lo-detengo"`. The one heading that is linked to carries
|
|
102
|
+
an explicit `{#puedo-manejarlo-desde-claude-code}`. A sweep of the built site resolves all 5
|
|
103
|
+
anchor links across its 26 pages, 0 broken.
|
|
104
|
+
- **Release notes are deliberately not translated**: the page is generated from `CHANGELOG.md` at
|
|
105
|
+
build time, so a Spanish copy would drift. The `/es/` sidebar links the English page and says so.
|
|
106
|
+
|
|
107
|
+
- **A public documentation site, written for people who have never seen tldrx (`docs-site/`, phase 1).**
|
|
108
|
+
A VitePress site deployed to GitHub Pages by `.github/workflows/docs.yml` on any push to `main` that
|
|
109
|
+
touches `docs-site/` or `CHANGELOG.md`. Twelve short English pages — a landing page, a Quickstart, one
|
|
110
|
+
page per concept (the five stages, files-as-state, gates, evidence, budgets), four guides and a
|
|
111
|
+
condensed CLI overview — plus a generated changelog and a Spanish placeholder. None of them is pasted
|
|
112
|
+
from `docs/`, which stays the agent-facing reference. Every command
|
|
113
|
+
and every block of output on the Quickstart was produced by running the real binary; nothing on the
|
|
114
|
+
site documents a flag that `--help` does not.
|
|
115
|
+
- **The changelog page is generated, never copied.** `docs-site/scripts/gen-changelog.ts` reads this
|
|
116
|
+
file at build time and emits one line per entry, so a release note reaches the website without
|
|
117
|
+
anybody maintaining a second copy of it. The generated page is gitignored for the same reason.
|
|
118
|
+
- VitePress dead-link checking is left ON and the build is green with it — proven by a probe, not
|
|
119
|
+
assumed: a deliberate link to a missing page failed the build with `1 dead link(s) found`. (Anchors
|
|
120
|
+
are NOT checked by it, so `#fragment` targets were verified against the rendered HTML by hand.)
|
|
121
|
+
- i18n is wired now, with the English content at the root and a Spanish placeholder under `/es/`, so
|
|
122
|
+
phase 2 is a matter of adding files rather than restructuring the site.
|
|
123
|
+
- `docs-site/` is excluded from the npm package (it is not in `files:`) and from `tsc --noEmit` (the
|
|
124
|
+
root tsconfig includes only `bin`, `src`, `test`) — measured: `npm pack --dry-run` still lists 52
|
|
125
|
+
files and none of them is under `docs-site/`.
|
|
126
|
+
|
|
127
|
+
- **`tldrx learn` chapters 3-8 — the whole loop, played (#30, phase 2).** The tutorial now runs end to
|
|
128
|
+
end in about five seconds of real commands: **3** the gate (`approve --note`, and the record it writes
|
|
129
|
+
in `run.yml`), **4** one story built for real (How's `auto` gate closing itself over its seven
|
|
130
|
+
conditions, Plan's human gate, then a Build that cuts `epic/bulk-pricing`, spawns a developer in a
|
|
131
|
+
worktree, re-runs the story's `npm run test` DoD, commits, merges and spawns a reviewer), **5** a
|
|
132
|
+
genuinely red DoD and the three commands back from it (`story reopen`, `reject`, `budget raise`),
|
|
133
|
+
**6** an `agent` gate closed by `approve --as-agent` over a structured evidence note, **7**
|
|
134
|
+
`run attend host`, the refusal a bare `next` then gives, and the `next --prepare` / `next --commit`
|
|
135
|
+
pair actually run, **8** `cost --all`, `run estimate`, and the budget brake refusing a stage the
|
|
136
|
+
phase can no longer afford.
|
|
137
|
+
- Chapter 4's DoD is real, chapter 5's failure is real: the story's test script is `exit 0` until a
|
|
138
|
+
developer replaces it with a `node` test that then catches a wrong number — so the tutorial teaches
|
|
139
|
+
"a green DoD over an empty test proves nothing" by letting it happen rather than by saying it.
|
|
140
|
+
- Chapter 5 opens a second run, so `{run}` and `{runDir}` now expand in a step's `command` as well as
|
|
141
|
+
in a turn's writes, and mean **the newest run that is still open** — the same set `resolveRun` picks
|
|
142
|
+
from, so the placeholder and the CLI cannot disagree about which run a command means.
|
|
143
|
+
- Chapter 4's `prepare()` commits what `init` left untracked, because the Build executor refuses to
|
|
144
|
+
cut a branch from a dirty tree (measured: `?? .gitignore, ?? CLAUDE.md`, exit 2) — and the narration
|
|
145
|
+
teaches that refusal instead of hiding it.
|
|
146
|
+
- **Fixed: the toy repo now carries its own git identity.** Chapter 4's Build commits through the
|
|
147
|
+
framework's own executor, with whatever identity the machine has — so on a box with no global
|
|
148
|
+
`user.email` (a fresh laptop, a container, `ubuntu-latest`) `git commit` failed with `Author identity
|
|
149
|
+
unknown` and the chapter died three commands in. `makeSandbox` writes `user.email`, `user.name` and
|
|
150
|
+
`commit.gpgsign=false` into the sandbox repo's own config, on every open, so an older sandbox is
|
|
151
|
+
repaired too. The test pins it with `user.useConfigOnly` rather than an empty global config: git
|
|
152
|
+
guesses an identity from gecos and hostname and only fails where it cannot, so an empty config
|
|
153
|
+
passes on a laptop and fails in a container — which is exactly how this reached CI.
|
|
154
|
+
|
|
155
|
+
- **`tldrx learn` — a playable sandbox tutorial that runs the REAL commands (#30, phase 1 of 3).**
|
|
156
|
+
A tutorial that runs the shipped binary can never drift from the shipped behaviour: every output the
|
|
157
|
+
learner reads is produced by the code, not written down by a doc author. `tldrx learn` scaffolds a
|
|
158
|
+
throwaway workspace (a four-file git repo with a `test` script that exits 0), then narrates, shows the
|
|
159
|
+
exact command, waits for Enter and RUNS it — `tldrx init`, `run new`, `next`, `answer` are the real
|
|
160
|
+
ones, against that sandbox.
|
|
161
|
+
- **Chapters 1 and 2 ship playable**: init (read the `workspace.yml` detection actually produced) and
|
|
162
|
+
the What stage (a question comes back, `tldrx answer` records it, it becomes `F001` in
|
|
163
|
+
`.tldrx/memory/facts.yml`). Chapters 3-8 from the issue are phase 2 and are DATA plus one
|
|
164
|
+
`assert()` — see the contract in `src/core/learn/Chapter.ts`.
|
|
165
|
+
- **It cannot spend money, by construction rather than by convention.** The sandbox writes its own
|
|
166
|
+
`claude` stand-in, names it in `TLDRX_CLAUDE_BIN` and puts it first on the child `PATH`, so neither
|
|
167
|
+
the spawn seam nor a bare `claude` on `PATH` can reach the real CLI. `test/learn.test.ts` proves it
|
|
168
|
+
the only way worth proving: it plants a booby-trapped `claude` that writes a marker file, plays both
|
|
169
|
+
chapters, and asserts the marker is absent AND the chapters completed — because a tutorial that
|
|
170
|
+
spawned nothing would pass a marker check for the wrong reason.
|
|
171
|
+
- **It cannot touch your work.** Everything is written under the sandbox directory (`~/.tldrx-learn`
|
|
172
|
+
by default, `--sandbox` to move it), and a sandbox that would sit inside a real tldrx workspace is
|
|
173
|
+
refused before a byte is written.
|
|
174
|
+
- **Files as state, like everything else**: `progress.json` in the sandbox is what makes a bare
|
|
175
|
+
`tldrx learn` resume, `--chapter <n>` jump (playing an unfinished prerequisite first), and `--reset`
|
|
176
|
+
start over. With no terminal on stdin the chapters play straight through rather than hanging at the
|
|
177
|
+
first prompt.
|
|
178
|
+
- The stand-in agent is scripted per chapter and is **fail-closed**: a prompt no turn matches exits 1
|
|
179
|
+
and names the turns it did have, so a hole in the tutorial is a loud failure rather than an
|
|
180
|
+
improvised answer. The `stream-json` writer moved from `test/fixtures/fakeStream.ts` to
|
|
181
|
+
`src/core/facilitator/fakeTranscript.ts` — beside `agentEvents.ts`, which reads that format — so the
|
|
182
|
+
tutorial's stand-in and the four test fakes cannot drift apart. The fixture re-exports it.
|
|
183
|
+
|
|
184
|
+
- **`tldrx note <run> [--stage <id>] "text"` — an honest carrier for an operator annotation, at the
|
|
185
|
+
moment it happened (#46).** Measured on `260829-scoring-leaderboard` (2026-09-01): a host performed an
|
|
186
|
+
owner-delegated mechanical resync of eight story dod blocks, was asked to note it in the run log, and
|
|
187
|
+
could not — `events.jsonl` is append-only and tool-owned, so the only carriers were a FUTURE gate note
|
|
188
|
+
(late, and keyed to a decision the note is not about) or a `reject` (destructive). The session ended up
|
|
189
|
+
hanging the context off an unrelated `story.reopened`.
|
|
190
|
+
- **One event, and nothing else.** It appends a single `operator_note` line carrying actor, timestamp,
|
|
191
|
+
optional stage and the text. It does not go through `RunStore.save()`, which would rewrite `updated_at`
|
|
192
|
+
and re-derive every status for an annotation that changed no state: `test/operator-note.test.ts`
|
|
193
|
+
compares `run.yml` and `budget.yml` **byte for byte** across the call, because "safe to reach for
|
|
194
|
+
mid-run" is the whole of what makes the verb usable.
|
|
195
|
+
- **Every refusal writes nothing.** An unknown run (exit 3), a stage this run does not have (exit 2), an
|
|
196
|
+
empty note (exit 1) and a lone argument that turns out to name a run all refuse before the log is
|
|
197
|
+
opened. The last one is the trap worth naming: `tldrx note 260829-x` is a half-typed command, not a
|
|
198
|
+
note whose entire content is a run id, and recording it would be the one outcome nobody wanted.
|
|
199
|
+
- **Visible afterwards.** `tldrx run status` prints the last three (with `tldrx replay` named when there
|
|
200
|
+
are more), `--json` carries them as `operator_notes`, and `tldrx replay` narrates every one in place.
|
|
201
|
+
|
|
202
|
+
- **`tldrx run gates set <stage>:<policy> --note "…"` — the signed upgrade path for a frozen
|
|
203
|
+
`gates_policy` (#14).** The policy is resolved at `run new` and frozen into `run.yml` by design, and that
|
|
204
|
+
default is not taken back here. What it left with no door at all, found on the 2026-08-30/31 unattended
|
|
205
|
+
pilots: a run created BEFORE the `agent` policy existed can never use `approve --as-agent`, and `run.yml`
|
|
206
|
+
is hand-edit-forbidden (spec §1) — so the only move was to abandon the run.
|
|
207
|
+
- **Built like `story reopen`, because it is the same kind of act:** a person overruling state the machine
|
|
208
|
+
is holding. A `--note` is required; ONE stage per invocation (a list would let a second change ride on
|
|
209
|
+
the first one's signature); the entry must be QUALIFIED, because under `--gates` a bare `plan` means
|
|
210
|
+
`human` and a signature must not rest on a default; and a no-op is refused rather than recorded.
|
|
211
|
+
- **One `gate.policy_changed` event** carries the phase, who signed it, the old and the new value, and the
|
|
212
|
+
note. A run with no `gates_policy:` at all gets the FULL map written — every stage explicitly, with the
|
|
213
|
+
one change applied — because a partial map would quietly claim its other stages had been decided too.
|
|
214
|
+
- It changes who may CLOSE a gate from then on. Gates already signed are untouched, and nothing re-reads
|
|
215
|
+
the policy of a closed one.
|
|
216
|
+
|
|
217
|
+
- **`tldrx ship` — open a PR from the run's epic branch, with the handoff as the body (#15).** The loop
|
|
218
|
+
ended at "merge by hand": a finished epic sat on `epic/<slug>`, the document explaining it sat in
|
|
219
|
+
`<run>/<phase>/handoff.md`, and nothing carried either one to a PR.
|
|
220
|
+
- **It never pushes.** `core/build/git.ts` has no `git push` wrapper on purpose (spec §5), and this verb
|
|
221
|
+
keeps that rule rather than being the exception to it: a branch the remote has not seen is a refusal
|
|
222
|
+
that names the exact `git -C … push -u` command. Publishing a branch is a decision, and it stays the
|
|
223
|
+
operator's.
|
|
224
|
+
- **It never writes to the run** — no event, no gate, no cursor, no money — and it does not mirror
|
|
225
|
+
tickets: `tldrx tickets sync` already is that verb, holds the `process.yml` contract and appends
|
|
226
|
+
`ticket.synced`. A second, thinner mirror inside `ship` would give the workspace two answers to "is this
|
|
227
|
+
story mirrored", so `ship` names it as the next step instead.
|
|
228
|
+
- **Clean refusals, in a sentence:** no epic branch, no handoff, no `gh`, no remote, an unpushed branch,
|
|
229
|
+
several epic branches with no `--branch`, a branch the run did not cut. The body goes to `gh` as
|
|
230
|
+
`--body-file`, never as an argument, so a long handoff cannot overflow an argv limit.
|
|
231
|
+
- Both external binaries go through one narrow transport that takes a cwd — the same idea as
|
|
232
|
+
`adapters/transport.ts`, and the only way to ASSERT the argument shape of a command the suite must not
|
|
233
|
+
run. The unit tests drive a recording fake; the one end-to-end test puts a STUB `gh` first on PATH in a
|
|
234
|
+
throwaway workspace with a throwaway bare `origin`. The real `gh` is never invoked by a test.
|
|
235
|
+
|
|
236
|
+
- **`TLDRX_CLAUDE_BIN` — point the sub-agent spawn at a different binary (#27, minimal slice).**
|
|
237
|
+
`spawnAgent` hardcoded `claude`, so a pinned install, a wrapper that adds a proxy or credentials,
|
|
238
|
+
and a stand-in in a sandbox all required patching source. The variable replaces the executable
|
|
239
|
+
NAME and nothing else — the argv is still Claude Code's, so what it points at has to speak
|
|
240
|
+
`-p --output-format stream-json --json-schema` — and blank or whitespace counts as unset. Honoured
|
|
241
|
+
everywhere the CLI is spawned: `spawnAgent`, the `--dry-run` command line (`describeSpawn`) and
|
|
242
|
+
`claude mcp list` (`McpProbe`). `tldrx doctor` deliberately still checks `claude --version`,
|
|
243
|
+
because `env.yml` declares that string. Documented under **Environment variables** in the CLI
|
|
244
|
+
reference. This is not the provider abstraction #27 asks for; #27 stays open for it.
|
|
245
|
+
|
|
246
|
+
- **A drift guard on `templates/story.md` and `templates/epic.md` (#48).**
|
|
247
|
+
Both ship in the npm package, both state the Plan front-matter schema, and **nothing in `src/`
|
|
248
|
+
reads either one** — a second copy of a contract whose first copy is computed from `STORY_KEYS`
|
|
249
|
+
and `EPIC_KEYS`. Add a required key and `schemaContract.ts` stops compiling while the templates
|
|
250
|
+
say nothing; a human then opens one, writes a story the check refuses, and the framework looks
|
|
251
|
+
broken. They now go through `validateStoryFile` / `validateEpicFile` — the very checks the stage
|
|
252
|
+
gates on — with their key sets asserted equal to `STORY_KEYS` / `EPIC_KEYS` in order, and the
|
|
253
|
+
status enum each spells out in a comment asserted equal to `PLAN_STATUSES`. Proven to have teeth:
|
|
254
|
+
renaming one key and staling one enum comment turns three tests red. Whether the files should be
|
|
255
|
+
generated or deleted is a packaging decision and is left open on #48.
|
|
256
|
+
|
|
257
|
+
- **The merge-wave sandbox is built under a hostile `init.defaultBranch` (#49).**
|
|
258
|
+
`test/merge-wave.test.ts` names every repo `main`, and CI run 33459567355 failed in the test's
|
|
259
|
+
own setup — `git push -q origin main` → `src refspec main does not match any` — on a runner whose
|
|
260
|
+
default branch is not `main`. `f1ffe56` had already fixed it (`-b main` on both inits,
|
|
261
|
+
`--branch main` on the clone), but nothing EXERCISED the fix: on a `main`-defaulting host,
|
|
262
|
+
removing the treatment changes nothing. The sandbox now pins `init.defaultBranch: trunk` for
|
|
263
|
+
every git command it builds itself with, the clone asserts it is on `main` rather than
|
|
264
|
+
discovering it five lines later, and two tests pin the mechanism — untreated reproduces the CI
|
|
265
|
+
error verbatim, treated does not.
|
|
266
|
+
|
|
267
|
+
- **The Plan prompt now STATES the schema the `plan` check enforces, generated from the check itself (#35, #38).**
|
|
268
|
+
`stages/plan/stage.md` named the output filenames — `stories/<id>.md`, `epics/<epic>.md`, `waves.yml` — and
|
|
269
|
+
said nothing about their shape, so a fresh agent learned it by having a paid attempt refused. Measured twice
|
|
270
|
+
in two days: on `260831-hardening-d1` the plan sub-agent followed the rendered bundle faithfully and wrote
|
|
271
|
+
seven stories as plain markdown (`no YAML front matter — the file must open with ---`), and on
|
|
272
|
+
`260829-scoring-leaderboard` it wrote a 1,009-character acceptance item against a `MAX_ITEM_CHARS = 512`
|
|
273
|
+
cap that appeared in no file it could read. Both attempts were consumed, correctly and uselessly.
|
|
274
|
+
- **Generated, not copied.** `src/core/plan/schemaContract.ts` renders a `## Output schemas` section from
|
|
275
|
+
`STORY_KEYS`, `EPIC_KEYS`, `PLAN_STATUSES` and the six `MAX_*` constants — the same definitions
|
|
276
|
+
`validateStory`, `validateEpic` and `validateWaves` read. `Record<StoryKey, Field>` is load-bearing:
|
|
277
|
+
add a key to a schema and the file stops compiling until the new key has a value and a rule. The worked
|
|
278
|
+
examples it ships are run through `validatePlan` itself in the tests, so the contract the prompt states
|
|
279
|
+
and the contract the check enforces are provably the same one. This repo already had the other kind:
|
|
280
|
+
`templates/story.md` carries the schema correctly and `grep -rn 'story\.md' src/` finds nothing that
|
|
281
|
+
reads it.
|
|
282
|
+
- **Only where the check runs.** `applyCheckContracts` splices it under an H2 the framework owns, for a
|
|
283
|
+
stage that declares `checks: [plan]` AND writes `waves.yml` — the same predicate `checkPlan` skips on,
|
|
284
|
+
now shared (`writesPlanArtefacts`). A What or How prompt is byte-identical to before. It goes into
|
|
285
|
+
`stage.md` rather than after the inputs because `prompt.ts` orders the document most-stable-first for
|
|
286
|
+
the prompt cache, and a section computed from constants is exactly as stable as the stage body.
|
|
287
|
+
- **~4.2 KB against a $4 stage.** The alternative it replaces is a refused attempt per fresh workspace.
|
|
288
|
+
|
|
289
|
+
- **`tldrx plan sync-dod` — the mechanical repair for dod blocks an edited `workspace.yml` orphaned (#42).**
|
|
290
|
+
A story's ```dod block may only name commands `workspace.yml` declares, byte for byte, and that rule is not
|
|
291
|
+
relaxed by a byte here — it is what stops a data file from running an arbitrary command as you. What it
|
|
292
|
+
lacked was an inverse. Measured live 2026-08-31 on `260829-scoring-leaderboard`: fixing `workspace.yml`
|
|
293
|
+
(a filtered `test:`, `lint:` deleted) instantly invalidated the dod blocks of **8 approved stories**, and
|
|
294
|
+
the only recoveries were hand-editing agent-approved artefacts or re-running the whole Plan stage — a paid
|
|
295
|
+
turn to change two lines in eight files, churning thirteen correct stories on the way.
|
|
296
|
+
- **Evidence, not similarity.** The ancestry comes from git's history of `.tldrx/workspace.yml`: a line a
|
|
297
|
+
previous version declared under a role the current file still has becomes that role's current command; a
|
|
298
|
+
line whose role is gone is dropped; a line the current file already declares is left alone; and a line no
|
|
299
|
+
version ever declared — or one two roles once shared and now disagree on — is **flagged, its story left
|
|
300
|
+
byte-identical**, and the command exits `2`. Guessing at a rename by string shape is the one thing this
|
|
301
|
+
must not do. In a workspace with no git history there are no ancestors, so everything non-current is
|
|
302
|
+
flagged.
|
|
303
|
+
- **It touches nothing else.** Front matter, prose, blank lines and the fences come back byte-identical;
|
|
304
|
+
only the command lines inside the fence move. The previous version is kept at `<story>.md.bak`
|
|
305
|
+
(`writeAtomic`), the summary is a per-story diff, `--dry-run` prints it and writes nothing, and the result
|
|
306
|
+
is validated by the same plan check the drift came from. Stories that CAN be synced still are — one
|
|
307
|
+
undecidable line is not a veto on the other seven files.
|
|
308
|
+
- **The drift message now names the remedy.** "`<cmd>` is not one of .tldrx/workspace.yml's commands — a
|
|
309
|
+
story may not invent one" gained a second sentence pointing at `tldrx plan sync-dod`. Only for a STORY:
|
|
310
|
+
a stage's `cmd:` is a line a human wrote, and `sync-dod` does not touch stage files.
|
|
311
|
+
|
|
312
|
+
- **`tldrx answer <Qn> "…" --supersede` — the verb for reversing a decision already on record.**
|
|
313
|
+
Found live 2026-08-31: an owner reversed an answered decision after the risk behind it was
|
|
314
|
+
refuted, and `tldrx answer` refused ("Q1 is not an open question") because an answer is
|
|
315
|
+
recorded once. `superseded_by` had been in the §2.5 schema since the first draft with **no
|
|
316
|
+
command that wrote it**, so the only route was a hand edit of `facts.yml` — and a hand edit
|
|
317
|
+
that left `superseded_by: null` left the reversed decision inside `FactsStore.active`, which
|
|
318
|
+
every stage reads as never-re-ask truth. The next run would have reinstated the call the owner
|
|
319
|
+
had just taken back.
|
|
320
|
+
- **The verb.** Valid only on an ANSWERED question (on an open one it exits `1` and says to
|
|
321
|
+
answer it normally; without the flag an answered one still exits `3`, now naming
|
|
322
|
+
`--supersede`). It appends a new fact carrying the whole new answer with the same `area` and
|
|
323
|
+
`repos` and ordinary provenance, sets the old fact's `superseded_by` and the new one's
|
|
324
|
+
`supersedes` — both halves, through `FactsStore.supersede`, under the workspace lock, so the
|
|
325
|
+
reciprocity rule cannot be broken — and never edits a byte of the old fact's text. Reversing
|
|
326
|
+
twice supersedes the SECOND fact: the chain is walked to its head, so it stays single-link.
|
|
327
|
+
- **The questions block is appended to, not rewritten.** The original `[Answer]:` line and its
|
|
328
|
+
footer stand; a superseding answer line and a `reanswered_by | reanswered_at | fact |
|
|
329
|
+
supersedes` footer go under them. `status:` stays `answered`, because it is.
|
|
330
|
+
- **Every reader that feeds a decision now skips a superseded fact.** This was half the work
|
|
331
|
+
and none of it was new code: `superseded_by` had a writer for the first time, and six readers
|
|
332
|
+
had been filtering on retirement alone. `isLive` (`core/facts/Fact.ts`) is now the one
|
|
333
|
+
predicate behind `FactsStore.active` (no-re-ask, `tldrx run new --from` de-duplication),
|
|
334
|
+
`findDuplicate`, `renderFacts` (the `{{facts}}` section of **every** prepared prompt),
|
|
335
|
+
`renderWatchFacts`, `runFacts` (the implicit plan's "this run's answers") and `relevantFacts`
|
|
336
|
+
(the training miner). One test had pinned the old behaviour in words — "a
|
|
337
|
+
superseded-but-not-retired row stays visible" — and that was the bug, not the rule.
|
|
338
|
+
- **History readers still show it, labelled.** `tldrx replay` renders the new `fact.superseded`
|
|
339
|
+
event as its own line — the one moment the workspace's durable memory changes its mind was
|
|
340
|
+
the one moment replay could not narrate — and `tldrx retro` lists the old fact with
|
|
341
|
+
`(superseded by F<n>)` beside it.
|
|
342
|
+
|
|
343
|
+
### Fixed
|
|
344
|
+
|
|
345
|
+
- **`tldrx learn` — the cold-player QA round (#30).** A first-time player played all eight chapters
|
|
346
|
+
and returned SHIP-with-fixlist. Everything they found is fixed or recorded:
|
|
347
|
+
- **Chapter 8 no longer lies about the brake.** It said "the phase has already spent its Watch
|
|
348
|
+
money" while the tool printed `$1.89 left … estimate is $2.00`. The real mechanism is that a
|
|
349
|
+
re-run is priced at the stage's WHOLE declared `budget_usd`, never at what a second attempt might
|
|
350
|
+
add — so a stage that has spent anything can no longer afford itself. The chapter now says that,
|
|
351
|
+
quotes both figures, and `assert()`s them against the `budget.blocked` event so the numbers cannot
|
|
352
|
+
drift away from the sentence.
|
|
353
|
+
- **Chapter 1 no longer promises something chapter 2 does not deliver.** `--no-interview` skips
|
|
354
|
+
*init's* setup interview, which no chapter covers; the forward reference is gone and the debrief
|
|
355
|
+
now sends the learner to `.tldrx/init-handoff.md`, where measured/inferred/assumed and
|
|
356
|
+
`[src: …]` / `absent:` actually live.
|
|
357
|
+
- **`tldrx learn --chapter <n>` refuses a chapter that is already played**, up front and by name,
|
|
358
|
+
instead of narrating it and then dying mid-chapter on `run new: … already exists` (exit 1,
|
|
359
|
+
measured). The refusal names `--reset` and the chapter a bare `learn` would resume at.
|
|
360
|
+
- **The tutorial has a door out.** The ending now names the first four commands to type on a real
|
|
361
|
+
repo — `tldrx init` (with the warning that it runs an interview by default), `run new --scope
|
|
362
|
+
hotfix`, `next`, and `tldrx ship`.
|
|
363
|
+
- **Chapter 7 RUNS `next --prepare` and `next --commit`** against the feature run's Watch stage
|
|
364
|
+
instead of describing them in a debrief. Chapters 6 and 7 swapped for it: the attended chapter
|
|
365
|
+
addresses the feature run through `{run}`, so the hotfix run has to be signed off first.
|
|
366
|
+
- **Every non-zero exit code is printed** (`→ exit 4`), so the code chapter 2 teaches is a thing
|
|
367
|
+
the learner reads rather than a thing they are told. Chapter 5 now also demonstrates the exit-2
|
|
368
|
+
refusal a bare `next` gives with two runs open, and names the two run-id spellings.
|
|
369
|
+
- Jargon defined at first use — expert, level 0, the `claim-sources` / `no-reask` / `budget-gate`
|
|
370
|
+
bracket, `boundary`, `[src: …]`, `absent:`, economy, §2.11 — and the `expert … has no evidence`
|
|
371
|
+
nudge explained once instead of repeating unexplained nine times.
|
|
372
|
+
- Known and NOT fixed: a step's stderr (where the agent stream lives) is buffered and printed after
|
|
373
|
+
its stdout, so a summary can appear before the stream that produced it. Interleaving needs an
|
|
374
|
+
`onStderrLine` on the runtime seam and in both implementations; documented in `engine.ts`.
|
|
375
|
+
|
|
376
|
+
- **`tldrx cost` no longer claims "two economies" over one (#56).** The `(no total: two economies, no
|
|
377
|
+
exchange rate)` footnote was unconditional, so a run whose every attempt was metered in dollars was
|
|
378
|
+
told no total could be printed. It is printed only when both economies are actually present.
|
|
379
|
+
|
|
380
|
+
- **The README's "Not on npm yet" warning was false and told readers not to run the install line
|
|
381
|
+
directly underneath it.** The package IS published: `npm view tldr-experts version` → `0.3.1`,
|
|
382
|
+
exit 0. The warning is removed rather than re-dated — the npm badge at the top of the README
|
|
383
|
+
already shows the live version, so nothing in its place can go stale the same way. The
|
|
384
|
+
`npm i -g tldr-experts` line it was contradicting is unchanged.
|
|
385
|
+
|
|
386
|
+
- **The site's own home page linked an anchor that does not exist.** The hero's "Try it offline,
|
|
387
|
+
free" button pointed at `/quickstart#try-the-whole-thing-first-for-free`, but the heading renders
|
|
388
|
+
as `id="first-try-it-for-free"` — verified against the LIVE page, not just a local build. Dead-link
|
|
389
|
+
checking never saw it because VitePress does not check fragments. Repointed, and a sweep of the
|
|
390
|
+
built site now resolves every anchor link it emits.
|
|
391
|
+
|
|
392
|
+
- **`tickets sync`, `tickets status` and `budget show` took a run id as a positional that neither
|
|
393
|
+
their `usage` nor their `--help` declared (#53).** Measured at `7ac298c`:
|
|
394
|
+
`tldrx tickets status zzz-positional-probe` and `tldrx budget show zzz-positional-probe` both reach the
|
|
395
|
+
run resolver and exit `3` with `no run 'zzz-positional-probe'`, so both forms have always been
|
|
396
|
+
supported. The mechanism is the same in each: the subcommand word is consumed by the dispatcher
|
|
397
|
+
(`tickets.ts:53`, `budget.ts:32`) before `stringFlag(args, "run") ?? args.positionals[0]` runs
|
|
398
|
+
(`tickets.ts:246`, `budget.ts:48`), so `positionals[0]` is a run id by then.
|
|
399
|
+
- **The capability is DECLARED, not removed.** `usage` now reads `tldrx tickets sync [<run>] …`,
|
|
400
|
+
`tldrx tickets status [<run>] …` and `tldrx budget show [<run>] …`, and both help entries gain the
|
|
401
|
+
`[<run>]` arg every other run-scoped command already carries. Nothing about what the CLI accepts
|
|
402
|
+
changed.
|
|
403
|
+
- **This is the axis #51's guard cannot see.** That guard compares the registry to the usage; here the
|
|
404
|
+
registry itself was narrower than the code, and where both are silent both are green. The new check
|
|
405
|
+
in `test/cli.test.ts` is a hand-written list — nothing derives a positional from source — but its
|
|
406
|
+
BEHAVIOURAL half spawns the real CLI against a throwaway workspace, so it also goes red if the
|
|
407
|
+
capability is ever removed, which is the direction a tidy-up of the arg parsing would break it in.
|
|
408
|
+
|
|
409
|
+
- **`tldrx run gates set` was documented nowhere in `docs/` (#54).** `grep -rn "gates set" docs/` returned
|
|
410
|
+
nothing at `7ac298c`, so the CLI reference — the page a reader lands on from the README — described
|
|
411
|
+
seven of `tldrx run`'s eight subcommands. It matters more than an ordinary docs gap because `gates set`
|
|
412
|
+
is the ONLY sanctioned way to move a `gates_policy` that `run new` froze, and the situation it exists for
|
|
413
|
+
(a run opened before the `agent` policy existed, which can otherwise never use `approve --as-agent`) is
|
|
414
|
+
one an operator hits mid-run and searches the docs for. What they found was "abandon the run".
|
|
415
|
+
- Documented in all three places the question gets asked from: the `tldrx run` usage block and a new
|
|
416
|
+
prose entry in `docs/guide/08-cli-reference.md`; a **Moving a frozen policy** section in
|
|
417
|
+
`docs/guide/03-runs-and-gates.md`, right under the paragraph that explains the freeze, with the
|
|
418
|
+
`--gate-agent` disclaimer further down now linking to it; and `docs/spec.md`, both in §2.2's
|
|
419
|
+
`gates_policy` row and as its own §CLI row (exits `0,1,2,3`, each measured).
|
|
420
|
+
- Every copy carries the two facts a usage line cannot: **`--note` is mandatory**, and the change
|
|
421
|
+
appends one **`gate.policy_changed`** event with the actor, the moment, the note and the old→new
|
|
422
|
+
value — the whole audit trail for a mutation nobody would otherwise go looking for.
|
|
423
|
+
- Guarded: `test/cli.test.ts` now asserts the CLI reference names every subcommand in
|
|
424
|
+
`runCommand.subcommands`, plus `gate.policy_changed` by name. Scoped to `run` on purpose —
|
|
425
|
+
`plan sync-dod` has no section on that page at all (a separate gap, unfiled), and `hook`'s seven
|
|
426
|
+
scripts are deliberately documented as one `<script>` slot.
|
|
427
|
+
|
|
428
|
+
- **The epic file duplicated every story's status, and nothing ever updated the copy (#50).**
|
|
429
|
+
Measured on `260829-scoring-leaderboard` (2026-09-01): `03-plan/epics/E1.md` listed S1, S2 and S3 as
|
|
430
|
+
`todo` in its `## Stories` table while `03-plan/stories/S1.md` said `done` (merged at `0a50660`,
|
|
431
|
+
`task.done` in `events.jsonl` at 23:46:11Z) and S2 said `in_progress`. Nothing had lied — nothing had
|
|
432
|
+
written, either. Fixed by **removing the copy**, not by adding a second writer, and the repo already
|
|
433
|
+
drew that line for the one field it does maintain: the epic's front-matter `status:` is DERIVED and
|
|
434
|
+
written by `BuildExecutor.updateEpicStatus` (`build.ts:2552`) from the story files. A copy with a
|
|
435
|
+
writer is a cache; a copy without one is a lie waiting to be read.
|
|
436
|
+
- **Nothing parsed the table.** `validateEpicFile` is front matter only ("the front matter is the whole
|
|
437
|
+
schema", `schemas/epic.ts:69`), `adapters/body.ts:50` mirrors an epic to a ticket as a bare list of
|
|
438
|
+
ids with no status, and the dashboard reads the front matter. A writer would have been maintaining a
|
|
439
|
+
document with no reader.
|
|
440
|
+
- **Both copies of the shape are fixed, not just the visible one.** `templates/epic.md` now points at
|
|
441
|
+
`03-plan/stories/<id>.md` instead of tabulating it, and the GENERATED contract the Plan sub-agent
|
|
442
|
+
reads (`schemaContract.ts`, spliced into the stage prompt) now says "Do NOT restate a story's status,
|
|
443
|
+
repo or `depends_on`" — without that, the next Plan agent invents the table again, which is how it
|
|
444
|
+
got there.
|
|
445
|
+
- `test/plan-schema-contract.test.ts` runs the issue's acceptance: build a plan from the shipped
|
|
446
|
+
templates, flip S1 to `done` through `updateStoryFront` (the writer the Build executor uses), then
|
|
447
|
+
grep the epic. Any `S<n>` + status word on one line of the epic body is a claim, and the claim set
|
|
448
|
+
must be empty.
|
|
449
|
+
|
|
450
|
+
- **Seven `usage` strings were narrower than the same command's `--help` (#51, after #25).**
|
|
451
|
+
`usage` is what a BAD invocation prints — `run.ts:85`, `questions.ts:38`, `tickets.ts:60`, `gate.ts:44`
|
|
452
|
+
and three more write `<cmd>.usage` to stderr — so it is the string an operator reads at the exact
|
|
453
|
+
moment they got the invocation wrong, and it was hiding flags the code accepts. Widened: `run attend`,
|
|
454
|
+
`run status`, `run estimate`, `run auto`, `run unlock` and `run cancel` now show `[--run <id>]`
|
|
455
|
+
(all six read `args.positionals[0] ?? stringFlag(args, "run")`); `tldrx next` shows it too
|
|
456
|
+
(`next.ts:48`); and `tldrx questions lint` shows the `[<run>]` positional it has always taken
|
|
457
|
+
(`questions.ts:49`, and `docs/guide/08-cli-reference.md` had been documenting it for longer than the
|
|
458
|
+
CLI admitted it).
|
|
459
|
+
- **The guard is subcommand-aware, and that is not gold-plating.** A plain
|
|
460
|
+
`usage.includes("--run")` calls `run` CLEAN, because the new `run gates set` line names `--run` —
|
|
461
|
+
measured, the naive check saw **one of run's seven gaps**. `test/cli.test.ts` scopes a flag that
|
|
462
|
+
declares a `sub:` to that subcommand's block of the usage, and falls back to the whole string for a
|
|
463
|
+
`sub:` that is a MODE rather than a word in argv (`dashboard --out {sub: "static"}`, which the first
|
|
464
|
+
draft reported as a gap it is not).
|
|
465
|
+
- **Three of the seven were spelling, not gaps, and are allowlisted with the reason:** `seed`'s
|
|
466
|
+
`<Qid> "<text>"`, `watch`'s `check <feature>` and `hook`'s enumerated script names all say the same
|
|
467
|
+
thing more specifically than the registry's general name. A fourth, `run`'s
|
|
468
|
+
`<stage>:<human|auto|agent>`, is the same case. The allowlist is itself checked: every entry must
|
|
469
|
+
still name a declared flag or arg, so a rename turns an exemption red instead of silent.
|
|
470
|
+
- **`tickets --dry-run` was left OUT on purpose.** `tickets sync` previews by default and `--apply` is
|
|
471
|
+
the write; advertising `--dry-run` would imply the opposite, and `test/money-safety.test.ts:319`
|
|
472
|
+
asserts its absence. Recorded in the allowlist as a decision rather than papered over as a gap.
|
|
473
|
+
|
|
474
|
+
- **A literal ESC byte in `McpProbe.ts`'s ANSI regex (#52).** `src/core/doctor/McpProbe.ts:11` wrote a raw
|
|
475
|
+
`0x1b` where `\x1b` was meant, so the source read `/<ESC>\[[0-9;]*m/g` and a reader — in a diff, in a
|
|
476
|
+
review, in a terminal, in most editors — saw `/\[[0-9;]*m/g`, a different and wrong-looking regex that
|
|
477
|
+
someone tidying is one keystroke from breaking `tldrx doctor --mcp` with. **Not the #47 hazard**, and
|
|
478
|
+
worth saying: ESC does not trip the binary-file heuristic, and the file was always visible to a grep.
|
|
479
|
+
Behaviour is byte-identical, and `.source` is the wrong instrument for proving that (it returns the
|
|
480
|
+
literal as written, so the two spellings differ there while compiling to the same matcher) — so
|
|
481
|
+
`test/doctor.test.ts` compares the shipped `stripAnsi` against a reference rebuilt from the old
|
|
482
|
+
literal-ESC form over a nine-line corpus, 4 of which change. `test/source-hygiene.test.ts` now flags a
|
|
483
|
+
raw ESC as well as a NUL: measured across all 479 `.ts` files under `src/`, `test/`, `bin/` and
|
|
484
|
+
`scripts/`, `McpProbe.ts` held the only one, so the check has no false positives to trade against.
|
|
485
|
+
|
|
486
|
+
- **A stray NUL byte made two source files invisible to every grep-based sweep (#47).**
|
|
487
|
+
`test/cli.test.ts` carried one literal `0x00`, so `file(1)` called it `data` and `grep -I` —
|
|
488
|
+
ripgrep and ugrep too — dropped it SILENTLY, exit 0, no message. Measured on `origin/main`:
|
|
489
|
+
`grep -lI -E 'node:child_process|Bun\.spawn' test/*.ts` returned **36 files with `cli.test.ts`
|
|
490
|
+
absent**, though it calls `Bun.spawn` on line 32. That is how it missed #43's load-aware timeout
|
|
491
|
+
and then timed out at 5004 ms on the very merge that was fixing timeouts. Writing the guard found
|
|
492
|
+
a **second** one nobody had reported — `src/core/text/srcToken.ts:711`, a NUL used as a cache-key
|
|
493
|
+
separator, which hid that file from every `src/` sweep (367 of 368 `.ts` files visible). Both are
|
|
494
|
+
now the two-character escape `\0`: identical at runtime, ordinary text on disk. Post-fix the same
|
|
495
|
+
sweep finds `cli.test.ts` and all 368 `src/` files. `test/source-hygiene.test.ts` walks `src/`,
|
|
496
|
+
`test/`, `bin/` and `scripts/` and fails on any NUL, with the offender named at `path:line`.
|
|
497
|
+
|
|
498
|
+
- **The five wave-5 docs-pass nits, each a sentence nothing was checking (#25).**
|
|
499
|
+
- **`boundary.ts` promised an exclusion is "never silent" and dropped state paths without a
|
|
500
|
+
word** — `BoundarySurface.excluded` was populated and read by nothing. Every verdict that has a
|
|
501
|
+
surface now names what was excluded, green and red alike, including the case where the
|
|
502
|
+
exclusion was ALL there was and the run therefore reported "declares no surface".
|
|
503
|
+
- **The precondition refusal asserted "the stage is still `ready`" without looking.** It reports
|
|
504
|
+
the status `run.yml` actually holds. Bigger than filed: on a FRESH run the stage at the cursor
|
|
505
|
+
is `pending`, not `ready`, so the old sentence was wrong in the ordinary case as well as on the
|
|
506
|
+
retry of a `failed` stage.
|
|
507
|
+
- **The agent-gate fallthrough printed its label twice** — `boundary: boundary=…`, because every
|
|
508
|
+
condition detail was prefixed with its own id including the two that have a trigger of their
|
|
509
|
+
own. Only the generic `condition` trigger keeps the prefix; alone it names nothing.
|
|
510
|
+
- **`dispatchNotes.ts` documented `.agent/04-build/build/S5/…`**, one phase segment more than
|
|
511
|
+
`dispatchNotesPath` builds. The example is now asserted equal to the path the code produces.
|
|
512
|
+
- **Two usage strings were narrower than their own `--help`**: `tldrx gate template` omitted the
|
|
513
|
+
positional `[<run>]` it accepts, and `run new` spelled `--gates <a,b|all|none>` where the help
|
|
514
|
+
says `<a,b|a:agent|all|none>`.
|
|
515
|
+
|
|
516
|
+
- **`tldrx next --dry-run` spawns nothing. It used to cost $0.42 a go (#17).** The flag ran the
|
|
517
|
+
stage for real — one `claude -p`, one `agent.spawned`, one `agent.result`, the cost on the
|
|
518
|
+
ledger — and only reverted the non-handoff FILES afterwards. Measured on the 2026-08-30 pilot;
|
|
519
|
+
`tldrx next --help` had said "Spawns nothing and writes nothing" the whole time, so this is the
|
|
520
|
+
code catching up to the promise rather than the promise being watered down to the code.
|
|
521
|
+
- **What it does now.** It assembles the prompt, prices it, and stops: the expert bundle, the
|
|
522
|
+
context ledger, the prompt size, the declared outputs, and the **exact `claude -p` argv** it
|
|
523
|
+
would have run (with the `--json-schema` blob elided as `<envelope-schema>`, rendered from
|
|
524
|
+
`buildClaudeArgs` itself so the printed command cannot drift from the real one). Then the two
|
|
525
|
+
commands that would actually dispatch it. Exit `0`.
|
|
526
|
+
- **Nothing is written either.** No prompt bundle and no `pending.json`, so a dry run cannot
|
|
527
|
+
leave a `--commit` looking at a turn that never happened; the stage keeps its status and the
|
|
528
|
+
ledger keeps its zero. `dry_run_allowed: false` still refuses (Build sets it: a stage that cuts
|
|
529
|
+
branches and fans out per-story sub-agents has no ONE dispatch to describe).
|
|
530
|
+
- **On an attended run it is still refused at exit `4`** — but for the right reason now. It costs
|
|
531
|
+
nothing; it describes a dispatch the framework never makes there, and `--prepare` writes the
|
|
532
|
+
bundle the host is going to carry. The message said "it spawns a real sub-agent" and no longer
|
|
533
|
+
lies.
|
|
534
|
+
|
|
535
|
+
- **A precondition gets its own clock, not the stage's 900–1800 s (#20).** `preconditions:`
|
|
536
|
+
inherited `timeout_s`, so one hung command — `docker info` against a dead daemon is the measured
|
|
537
|
+
case — could hold a run for half an hour: exactly the waste the feature exists to prevent, taken
|
|
538
|
+
by the guard instead of by the attempt. Each precondition now gets **60 s** by default
|
|
539
|
+
(`PRECONDITION_TIMEOUT_S`), overridable per entry with `timeout_s: <n>`, refused at load if that
|
|
540
|
+
is not a number `> 0`. A timeout is a red precondition like any other — exit `2`, nothing
|
|
541
|
+
written, nothing spawned, the stage where it was — and its message names the precondition, its
|
|
542
|
+
own timeout and the knob that changes it, rather than the stage's. `CommandRun` gained
|
|
543
|
+
`timedOut` so a timeout can be told from a refusal or a wrong exit code without reading prose.
|
|
544
|
+
|
|
545
|
+
- **The budget gate can see host-token spend and attendedness (#22).** The tolerant reader the
|
|
546
|
+
`budget-gate` hook and the status line share (`hooks/lib/runFile.ts`) skipped `tasks[]` and
|
|
547
|
+
`attended_by:` entirely. So a run whose turns a host session paid for reported `$0.00` metered
|
|
548
|
+
and nothing else, and `runSnapshot`'s tolerant path hard-coded `attendedByHost: false` with a
|
|
549
|
+
comment admitting it meant "cannot see". `RunView` now carries `attended_by` and each task's
|
|
550
|
+
`cost_usd` / `metered` / `tokens`; `runSpend` derives the metered dollars, the declared host
|
|
551
|
+
tokens and the uncosted turns; `renderRunEconomies` renders the one line that says a dollar
|
|
552
|
+
figure is a lower bound. The gate appends it to a `host-tokens` phase's stderr note and to a
|
|
553
|
+
refusal, and `budget.blocked` records `economy`, `attended_by`, `metered_usd`, `host_tokens` and
|
|
554
|
+
`unmetered_tasks`. **No verdict changed**: a dollar ceiling still governs dollars, the two
|
|
555
|
+
currencies are still never converted, and a plain metered run's refusal is byte-identical.
|
|
556
|
+
|
|
557
|
+
- **The `max_reads` flake was a real race, not a slow test (#24).** A chunk boundary is not a line
|
|
558
|
+
boundary: `LineSplitter` hands every complete line in one chunk to the read counter
|
|
559
|
+
synchronously, so when the OS coalesced the sub-agent's writes — which is what a loaded CI box
|
|
560
|
+
does — reads 4..20 were counted in the same tick as read 3, long before the `SIGKILL` just
|
|
561
|
+
ordered could land. `agent.result.payload.reads` was therefore a function of scheduling, and the
|
|
562
|
+
assertion pinning it to the cap cost two retries in one night. The counter now stops the moment
|
|
563
|
+
the cap fires, so what is recorded is the number of reads the cap ALLOWED. Pinned by a fixture
|
|
564
|
+
that makes the coalescing deterministic (`FAKE_CLAUDE_READS_BURST=1` — every read pair in one
|
|
565
|
+
write): pre-fix that reported 20 reads against a cap of 3.
|
|
566
|
+
|
|
567
|
+
- **One over-cap list item no longer cascades into false "S<id> has no file" errors (#37).**
|
|
568
|
+
`validatePlan` resolves cross-file references out of the set of stories that PARSED, so a story file that
|
|
569
|
+
failed its own validation was indistinguishable from one that was never written. Measured on the
|
|
570
|
+
`260829-scoring-leaderboard` session: `acceptance[3]` in `S8.md` was 1,009 characters against the 512 cap,
|
|
571
|
+
and the check reported three errors of which one was real — the other two said `S8 has no file in stories/`
|
|
572
|
+
about a file that was 5,794 bytes on disk. The operator only avoided a wasted pass by re-deriving the cause
|
|
573
|
+
from the validator source; an agent reading that message goes hunting for a missing file or rewrites
|
|
574
|
+
`waves.yml`.
|
|
575
|
+
- A reference to a story or epic whose FILE EXISTS is never reported as missing. It now reads
|
|
576
|
+
`S8 is unresolved because stories/S8.md failed validation — that file exists; fix the errors reported
|
|
577
|
+
against it and this one goes with them`, carries `cascade: true` on the `PlanIssue`, and covers the
|
|
578
|
+
id-mismatch case (`stories/S8.md declares id \`S9\``) as well as the invalid-file case. The epic side —
|
|
579
|
+
a story pointing at an epic whose own file did not validate — had the identical bug and the identical fix.
|
|
580
|
+
- `describePlanIssues` orders root violations ahead of cascades. Its window is three issues wide, so one
|
|
581
|
+
real defect cascading into four references could otherwise spend the whole window on consequences and
|
|
582
|
+
never name the cause.
|
|
583
|
+
- A story that is genuinely absent still reports `has no file`, with no cascade flag.
|
|
584
|
+
|
|
585
|
+
- **A refused list value now names the cap it broke, at the cap's current value (#38).**
|
|
586
|
+
The constants were interpolated already but the sentence was not self-describing: `513 characters exceeds
|
|
587
|
+
the 512 cap` did not say the cap is per-item or that splitting the item is the fix. Now
|
|
588
|
+
`513 characters exceeds the 512-character cap on one list item — split it into several items` and
|
|
589
|
+
`65 items exceeds the 64-item cap`, both still derived from `MAX_ITEM_CHARS` / the list's own `max`, and
|
|
590
|
+
the same constants are what the Plan prompt states up front.
|
|
591
|
+
|
|
592
|
+
- **The merge itself is now serialised, and a gate can no longer describe a tree it is not pushing
|
|
593
|
+
(#44).** `scripts/merge-wave.sh` merges, gates and pushes in ONE shared checkout and took no lock.
|
|
594
|
+
Measured on the pre-fix script with two concurrent invocations against a real sandbox repo: run A
|
|
595
|
+
gated `7afcc0e` (its own merge) at `typecheck` and `fadc923` (the OTHER run's merge, landed
|
|
596
|
+
mid-gate) at `build`, then printed `OK fadc923 … pushed`. Both runs reported the same sha and
|
|
597
|
+
both exited 0 — a green report over a tree neither had finished gating. With a red change in the
|
|
598
|
+
other branch the same interleaving hands agent A a `FAIL build=1` for code it never wrote, which
|
|
599
|
+
is what was actually observed live 2026-08-31 (`2184` tests counted where the branch had `2181`).
|
|
600
|
+
- **A lock, held from before the dirty-tree check through the push.** `mkdir` on
|
|
601
|
+
`.git/merge-wave.lock` — atomic on macOS and Linux, where `flock(1)` is not on stock macOS, and
|
|
602
|
+
in `git rev-parse --git-common-dir` so the lock can never be dirt in the tree it guards — and
|
|
603
|
+
not `$R/.git`, which in a linked worktree is a FILE that `mkdir` can never turn into a lock. A second invocation WAITS,
|
|
604
|
+
saying so on stderr (`merge-wave: waiting for another merge in this checkout (owner: …)`) so the
|
|
605
|
+
single summary line on stdout stays a single line. Waiting is bounded (`MW_LOCK_WAIT_S`, default
|
|
606
|
+
3600 → exit `6`), and a lock whose owner is a dead pid on this host, or older than
|
|
607
|
+
`MW_LOCK_STALE_S`, is broken open — after re-reading the owner line, so two waiters cannot tear
|
|
608
|
+
down a lock a third has just taken. An interrupted run hands the lock back on its way out: an
|
|
609
|
+
untrapped signal kills bash WITHOUT running its `EXIT` trap, so `INT` and `TERM` are trapped too. No
|
|
610
|
+
path through the wait loop is free of the budget, including the break-open one: a lock that
|
|
611
|
+
cannot be created or removed now fails in under a second instead of spinning forever.
|
|
612
|
+
- **And an assertion that does not depend on the lock.** Between the last gate and the push, HEAD
|
|
613
|
+
must still be the commit the gates ran against; if it moved, nothing is pushed and the script
|
|
614
|
+
exits `5` saying which sha it gated and which one is there now. The lock prevents the race; this
|
|
615
|
+
makes pushing an ungated HEAD impossible even for someone who bypasses the lock. The pre-fix
|
|
616
|
+
script, given the same mid-gate commit, pushed it and reported `OK`.
|
|
617
|
+
- **It pushes the commit it gated, not the `main` ref.** `git push origin main` publishes
|
|
618
|
+
`refs/heads/main` whatever HEAD is — and a red gate leaves `main` sitting on an ungated merge
|
|
619
|
+
commit by design, so the next run from a detached or repaired HEAD would have published THAT.
|
|
620
|
+
The push is `HEAD:main` now, and a pre-flight refuses (exit `7`) when the gated commit is not a
|
|
621
|
+
fast-forward of `origin/main` rather than letting the server's rejection be the first news.
|
|
622
|
+
- Gate logs moved from the fixed `/tmp/mw-*.log` to a per-invocation `${TMPDIR}/mw-<pid>/`, and the
|
|
623
|
+
FAIL lines name the directory. Two runs in two clones on one box shared those files.
|
|
624
|
+
|
|
625
|
+
- **The test suite no longer goes red because the machine was busy (#43).** On an untouched
|
|
626
|
+
`origin/main`, `bun test` reported `2155 pass · 5 fail` while the same two files alone reported
|
|
627
|
+
`91 pass · 0 fail`: four tests that spawn a REAL `git` expiring on bun's 5000 ms default, and one
|
|
628
|
+
50 ms performance budget measured at 66.4 ms, with three `tldrx` runs and two other agents sharing
|
|
629
|
+
the box. Because `merge-wave.sh` refuses to push on any test failure, that red is indistinguishable
|
|
630
|
+
from a regression at the exact moment a merge is decided, and the natural response — re-run until
|
|
631
|
+
green — is how a real regression eventually gets pushed.
|
|
632
|
+
- **The clock moved; no assertion did.** `test/fixtures/machineLoad.ts` measures the machine
|
|
633
|
+
(1-minute run-queue per core, floored at 1 and capped at 8) and hands out budgets from it. All
|
|
634
|
+
**42** test files that spawn a real process — `git`, `bun`, the CLI — now open with
|
|
635
|
+
`setDefaultTimeout(spawnTestTimeout())`: 30 s idle, scaled by load, still a hang detector. How
|
|
636
|
+
long a process takes to start is a property of the machine, not of the code, so a fixed budget on
|
|
637
|
+
such a test measures the box. A test enumerates those files and fails if a new one skips the
|
|
638
|
+
budget. Serialising the suite would not have helped: `bun test` already runs files sequentially
|
|
639
|
+
in one process (verified — a `setDefaultTimeout` in one file does not reach the next, and a 5.5 s
|
|
640
|
+
test in that next file still expired at 5000 ms). The contention is other processes on the box,
|
|
641
|
+
which only a load-aware budget can see.
|
|
642
|
+
- **The first version of that list was a `grep -l`, and it lied.** It returned 14 files and silently
|
|
643
|
+
omitted `cli.test.ts`, whose "every command's help lists an exit table" then timed out at 5004 ms
|
|
644
|
+
on the merge that was fixing timeouts. Cause: one stray NUL byte at `test/cli.test.ts:366` makes
|
|
645
|
+
the file `data` to `file(1)`, and grep drops binary files under `-I` without a word. The list is
|
|
646
|
+
built by READING every file now, and `cli.test.ts` is asserted to be in it. Filed as #47.
|
|
647
|
+
- **The one real performance budget keeps its teeth.** `handoff` on 256 KB is now the floor of
|
|
648
|
+
three runs against `perfBudgetMs(50)`, which on an idle machine is 50 — the identical assertion.
|
|
649
|
+
A stall inflates some runs and never the floor, and a function that genuinely takes 120 ms still
|
|
650
|
+
fails, which is itself a test.
|
|
651
|
+
|
|
652
|
+
- **`npm pack` output no longer refuses the next agent's merge (#45).** `tldr-experts-<version>.tgz`
|
|
653
|
+
was not ignored, and the dirty-tree guard refuses on ANY porcelain line, untracked included — so a
|
|
654
|
+
pack artifact left by a release check blocked the merge of whoever came next, someone who did not
|
|
655
|
+
create the file and could not know whether deleting it was safe. `*.tgz` is ignored now. The guard
|
|
656
|
+
is deliberately unchanged: an untracked file is still dirt, and a test holds it to that.
|
|
657
|
+
|
|
658
|
+
- **`claim-sources` reports every problem it found, over every declared `.md` output — and a
|
|
659
|
+
`file` src resolves against this run's epic worktree.** Four issues, one code path (#33, #34,
|
|
660
|
+
#23, #16), all four measured on the 2026-08-30/31 unattended pilot runs.
|
|
661
|
+
- **It reported ONE problem** (#33). `checkClaimSources` returned on the first file, the first
|
|
662
|
+
category and `unresolved[0]`, so a 226-bullet cap breach sat invisible behind a single bad
|
|
663
|
+
file path: fixing the visible one and re-running would have bought the next one at the price
|
|
664
|
+
of a full paid pass. Every file and every category is reported now, as a per-file summary
|
|
665
|
+
(`<file>: 3 unsourced bullet(s) on line(s) …; 2 unresolvable source(s) — …`), with file-level
|
|
666
|
+
problems such as the cap breach listed FIRST so 200 line numbers cannot bury them. Up to six
|
|
667
|
+
of a category are named and the rest become `(+N more)` — the same convention
|
|
668
|
+
`describeKnowledgeIssues` uses, and necessary because a check's `detail` is rendered inside
|
|
669
|
+
one-line summaries (`autoGate`, `next`).
|
|
670
|
+
- **It looked at ONE file** (#34). The filter was `endsWith("handoff.md")`, so the identical
|
|
671
|
+
violation refused the stage when it was written in `handoff.md` and passed in silence when it
|
|
672
|
+
was written in `design.md`, `contracts.md` or `scope.md` beside it — the pilot's pass-3
|
|
673
|
+
violation was caught only because it happened to be in the handoff. Every declared `.md`
|
|
674
|
+
output is read now, by both the gate check and the write-time hook: the four-section rule for
|
|
675
|
+
the files that ARE handoffs, and `validateCitations` for the ones that are not. That second
|
|
676
|
+
rule is deliberately narrower — a bullet with no citation is prose, but a `[src: …]` that WAS
|
|
677
|
+
written must parse, must resolve, and must obey `$ … → exit n` belonging only to an
|
|
678
|
+
`Evidence ledger`. A declared non-handoff output that was never written is still not a
|
|
679
|
+
failure; that is the `--commit` gap check's job.
|
|
680
|
+
- **The execution-claim validator reads the verb** (#23). `\bexit \d` missed "exits 0", which
|
|
681
|
+
is how a trainer writing normal English says it, so the claim slipped through the grammar the
|
|
682
|
+
rule exists to enforce while "exit 0" three words away was refused. Conjugation, an optional
|
|
683
|
+
"with", and the `code`/`status` spellings all match now; the digit is still required, so "the
|
|
684
|
+
exit path is documented" and "the exchange refuses an empty code" stay prose.
|
|
685
|
+
- **A `file` src resolves against this run's epic worktree** (#16). The Build phase commits
|
|
686
|
+
onto an epic branch and deliberately does not merge it, so a Watch-stage handoff ABOUT that
|
|
687
|
+
work had every `repo:src/…` citation refused for naming code the working tree does not have
|
|
688
|
+
yet — the stage's own evidence was rejected for being true. `.tldrx/worktrees/<repo>/_epic-<run>-<epic>`
|
|
689
|
+
is now a resolution base, tried before the working tree, for both the hook and the gate; the
|
|
690
|
+
path convention has one home (`core/paths.ts`) that the Build executor writes and the §2.8
|
|
691
|
+
resolver reads. Resolution also no longer stops at the first base where the file EXISTS but
|
|
692
|
+
is too short — a file truncated on the epic branch would otherwise deny a claim about the
|
|
693
|
+
line it still has on `main`. **Still open** (commented on #16): the epic worktree is removed
|
|
694
|
+
at the end of Build unless `--keep-worktrees`, so the default Watch stage has no tree on disk
|
|
695
|
+
to resolve against. Closing that means reading blobs out of the epic branch inside a hook
|
|
696
|
+
whose budget is 50 ms, or keeping epic worktrees for the life of the run — a design call, not
|
|
697
|
+
a mechanical one.
|
|
698
|
+
|
|
699
|
+
- **The Build DoD is a DELTA gate again: the base tree is checked before any story is charged (#41).**
|
|
700
|
+
A dod block proves one thing — *this story did not break the tree* — and nothing checked that the tree was
|
|
701
|
+
unbroken to begin with. Measured live 2026-08-31 on `260829-scoring-leaderboard`: of the three commands
|
|
702
|
+
`workspace.yml` declared, **two already failed on pristine main** — a bare `dotnet test` ran two `Live`-trait
|
|
703
|
+
tests that call paid Azure AI and that the repo's own CI excludes, and `dotnet format --verify-no-changes`
|
|
704
|
+
flagged 336 files in a repo whose CI never gates format at all. All 15 stories in the plan would have blocked
|
|
705
|
+
identically, each having spent a developer turn, and each told the operator the STORY was red.
|
|
706
|
+
- **Pre-flight at Build entry.** After the dirty-tree and foreign-epic refusals and before anything is
|
|
707
|
+
dispatched, every dod command the pending stories name is run once against the untouched base tree. A
|
|
708
|
+
non-zero exit refuses the stage (exit `2`, back to `ready`) naming the command, its exit code and the repo,
|
|
709
|
+
with no attempt spent and nothing charged.
|
|
710
|
+
- **In the repo's own checkout, not a fresh worktree.** That is the tree a human means by "the base": it has
|
|
711
|
+
the installed dependencies and tool state that make the command mean what the team thinks it means. A
|
|
712
|
+
pristine worktree would fail half the world's repos for want of `node_modules` and turn a safety net into
|
|
713
|
+
an outage.
|
|
714
|
+
- **Paid for once.** Results go to `04-build/preflight.yml` — files are the state — keyed by repo, command and
|
|
715
|
+
the base sha, and are read back by every later invocation of the run. A missing or unreadable cache is a
|
|
716
|
+
question, never a fault: a run that entered Build on an older binary measures lazily rather than erroring.
|
|
717
|
+
- **Attribution.** When a story's DoD does go red, the cached base result decides whose fault it is. A command
|
|
718
|
+
red on the base too halts the build with the same workspace-config error rather than blocking the story and
|
|
719
|
+
consuming its attempt. A command the gate declined to run is recorded `unmeasured` and excuses nothing.
|
|
720
|
+
|
|
721
|
+
- **The review handshake no longer swallows an unrecognized verdict, nor drops structured
|
|
722
|
+
findings.** Measured on `260831-hardening-d1` / S1 (2026-08-31). Two verdict grammars coexist
|
|
723
|
+
— gate evidence is `sign | sign-with-fixlist | refuse`, a story review is
|
|
724
|
+
`approve | fixlist | changes` — and the host-facing hint named neither, saying only "write
|
|
725
|
+
{verdict, summary, findings}". The host wrote `sign`. `parseReview` fail-closed it to `changes`,
|
|
726
|
+
correctly and **silently**: a clean fix-list verification round read as a second `changes`, the
|
|
727
|
+
story went `blocked`, and a `story reopen` cycle was the only way to record the verdict that
|
|
728
|
+
had been meant all along. Separately, `findings` was filtered with `typeof f === "string"`, so
|
|
729
|
+
the attempt-1 adversarial reviewer's seven `{severity, file, line, claim, evidence, fix}`
|
|
730
|
+
objects were dropped whole — the verdict survived, the evidence it rested on did not.
|
|
731
|
+
- **The contract is now stated where the host reads it.** Both `--commit --review` hints name
|
|
732
|
+
the enum: `verdict is one of approve | fixlist | changes, NOT the `sign`/`refuse` gate
|
|
733
|
+
vocabulary`.
|
|
734
|
+
- **The downgrade is announced.** Fail-closed is unchanged — an unreadable verdict is still
|
|
735
|
+
`changes`, never `approve` — but a verdict outside the enum now comes back on
|
|
736
|
+
`Review.verdictProblem` ("the reviewer's verdict `sign` is not approve|fixlist|changes —
|
|
737
|
+
recorded as `changes`"), is printed by the executor on the one path both doors pass through,
|
|
738
|
+
and rides in `findings` so the review log and the next attempt's `## Previous attempt` both
|
|
739
|
+
carry it. A DECLARED `fixlist` that fell short is untouched: `fixlistProblems` already says
|
|
740
|
+
that one out loud, and two sentences for one downgrade would read as two faults.
|
|
741
|
+
- **Structured findings are rendered, never dropped.** An object becomes
|
|
742
|
+
`[severity] file:line — claim · evidence: … · fix: …`; a shape nothing recognizes is kept as
|
|
743
|
+
JSON; a `findings` that is not an array is kept as one finding. An unreadable finding in the
|
|
744
|
+
log beats a finding that is not in the log.
|
|
745
|
+
|
|
746
|
+
- **A project stage override that supplies only `stage.yml` no longer swaps the stage body for an
|
|
747
|
+
empty one.** Reported from the 260829-scoring-leaderboard driver session (2026-08-31) and
|
|
748
|
+
reproduced here: `stage.md` was resolved by string-substituting `stage.yml` in the path the
|
|
749
|
+
preset had already picked, so creating `.tldrx/stages/plan/stage.yml` to tune one key moved the
|
|
750
|
+
BODY lookup into a directory that had none — and the miss was read as an empty string. The
|
|
751
|
+
context ledger printed `stage 1 B` where it had been 4.9 KB; the sub-agent would have been
|
|
752
|
+
dispatched with the inputs, the experts and the rejection note and **zero** stage instructions,
|
|
753
|
+
and nothing refused. `stageMdPath` now resolves per FILE, not per directory: the override's own
|
|
754
|
+
`stage.md` wins, else the packaged one is inherited, and a stage with no body anywhere is a
|
|
755
|
+
named `StageBodyError` rather than a silent empty prompt. Both readers — `next --prepare` and
|
|
756
|
+
the Watch executor — go through it.
|
|
757
|
+
|
|
758
|
+
- **`approve --as-agent`'s refusal now names the route that works on the run in front of you.**
|
|
759
|
+
It pointed only at `--gates <stage>:agent`, which is chosen at `run new` and frozen there — so
|
|
760
|
+
the one suggestion meant recreating a run already in flight. It now leads with the delegated
|
|
761
|
+
approve: read the agent's evidence note yourself and sign as you, `tldrx approve --note
|
|
762
|
+
"delegated: <agent> reviewed this, evidence at <path>"`, which keeps the gate's policy and puts
|
|
763
|
+
the provenance on the record. Found across the 2026-08-30/31 unattended pilots.
|
|
764
|
+
|
|
765
|
+
- **`budget raise <phase> <usd>` help said `<usd>` was "the new ceiling"; the source adds it.**
|
|
766
|
+
`raiseBudget` computes `ceiling_usd + amount` (`raiseBudget.ts:83`), so an operator following
|
|
767
|
+
the help over-raised — measured live on the scavtopia leaderboard run, a "$5.40 new ceiling"
|
|
768
|
+
command would have set $8.00. The arithmetic is what live runs depend on and is untouched; the
|
|
769
|
+
words move. `<usd>` is now "how much to ADD to that phase's ceiling — a delta, not a new
|
|
770
|
+
ceiling", with a note spelling out the $10 + $25 = $35 case and pointing at `budget show`, which
|
|
771
|
+
already prints the correctly-sized command.
|
|
772
|
+
|
|
773
|
+
- **`tldrx run new --from` stores an imported answer's own words, not a letter pointing at a file
|
|
774
|
+
it does not own.** AI-DLC records a chosen option as `[Answer]: C`, and the import stored
|
|
775
|
+
"<question> — C" verbatim; two facts became unreadable once aidlc was uninstalled and the source
|
|
776
|
+
file went with it (2026-08-30/31 pilots). The interview flow has always resolved a letter to the
|
|
777
|
+
option's text before recording (`interview/reply.ts:32-37`), and the import now does the same:
|
|
778
|
+
`parseAidlcQuestions` reads the lettered options (uppercase, and a space required after the
|
|
779
|
+
punctuation, so `- E.g. …` stays prose) and `answerText` resolves the answer against them; a
|
|
780
|
+
letter with no option behind it is stored as typed rather than invented. Conflict detection is
|
|
781
|
+
unchanged by the longer text: it keys on the QUESTION, as `hooks/no-reask.ts:54` already does,
|
|
782
|
+
because `findDuplicate` is Jaccard over tokens and therefore length-sensitive — the same
|
|
783
|
+
contradiction scored 0.78 against a bare letter and ~0.22 against the answer written out.
|
|
784
|
+
|
|
785
|
+
- **A second run's stories no longer merge into ANOTHER run's epic branch.** Measured live
|
|
786
|
+
2026-08-31 on two concurrent runs: `260831-hardening-d1` reported S1, S2 and S6 all
|
|
787
|
+
"merged into `epic/hardening-d1` (N commits carried)", and `epic/hardening-d1` was still
|
|
788
|
+
sitting at its base with **zero** story commits — all three merges had landed on
|
|
789
|
+
`epic/d1-tenancy-identity-customers`, a CLOSED previous run's branch. Nothing failed; the
|
|
790
|
+
run closed green with an empty epic, and it surfaced only because a later story measured
|
|
791
|
+
`git merge-base` and found the dependency it had been promised was missing.
|
|
792
|
+
- **The cause was one missing run id.** `openEpicWorktree` built the epic worktree's disk
|
|
793
|
+
path as `_epic-<epic id>`, and every plan names its first epic `E1`. The second run's
|
|
794
|
+
`existsSync` therefore hit the FIRST run's directory, `addWorktree` was skipped, and
|
|
795
|
+
`git merge --no-ff` ran inside a checkout of a foreign epic branch. The in-memory map was
|
|
796
|
+
keyed correctly (`repo:epicBranch`) — only the path collided, and only across processes.
|
|
797
|
+
Every progress line renders `story.epicBranch`, so the messages were right about where the
|
|
798
|
+
merge was *meant* to go for as long as the bytes went somewhere else.
|
|
799
|
+
- **The path now carries the run**: `_epic-<run id>-<epic id>`, the same shape the STORY
|
|
800
|
+
worktree was given after the 2026-08-29 audit found the identical class of bug one level
|
|
801
|
+
down. That fix never reached the epic worktree, which is the worse half — a story worktree
|
|
802
|
+
collision means two sub-agents editing one file, an epic worktree collision is a merge.
|
|
803
|
+
- **And a reuse on the wrong branch now refuses.** Every reuse of an epic worktree — the
|
|
804
|
+
remembered path and the one found on disk — asserts its checked-out branch is the story's
|
|
805
|
+
epic branch first (`assertWorktreeOn`, `core/build/git.ts`). A mismatch throws
|
|
806
|
+
`WorktreeBranchMismatchError` naming both branches and the directory, and fails the stage.
|
|
807
|
+
It never re-points the worktree and never merges anyway. Path scoping makes the collision
|
|
808
|
+
impossible; this makes it impossible to repeat *silently*.
|
|
809
|
+
|
|
810
|
+
- **A `--note` with a blank line in it no longer destroys `run.yml`.** Measured 2026-08-31 on the
|
|
811
|
+
live `260829-scoring-leaderboard` run: `tldrx reject --note "<two paragraphs>"` wrote the note
|
|
812
|
+
into the gate's flow mapping with LITERAL newlines inside a double-quoted scalar, which is not
|
|
813
|
+
YAML — the `yaml` package answered `Missing closing " quote at line 57` and Bun's parser
|
|
814
|
+
`Unexpected character` — and from that moment **every** command on the run failed. There was no
|
|
815
|
+
repair verb, so the operator had to hand-edit a file the docs forbid editing, and the next save
|
|
816
|
+
re-emitted the same string and broke it again at the same line, taking `run.yml.bak` with it.
|
|
817
|
+
Four changes, each closing one part of the loop:
|
|
818
|
+
- **The emitter escapes, at the one place every field goes through.** `yamlScalar`
|
|
819
|
+
(`core/facts/emitFactsYaml.ts`) escaped `\` and `"` and nothing else; it now emits via
|
|
820
|
+
`JSON.stringify`, whose string grammar is a strict subset of YAML 1.2's double-quoted scalar —
|
|
821
|
+
the escaping this repo already trusted in `adapters/external.ts` and `build/storyFile.ts`.
|
|
822
|
+
Because every YAML this framework hand-emits routes strings through that one helper, the fix
|
|
823
|
+
reaches **all** of them at once: gate notes (`approve`, `reject`, `revoke`), `cancelled.note`,
|
|
824
|
+
task `error` and `stopped_by`, gate `evidence`, run `title`/`scope`, `facts.yml` fact text and
|
|
825
|
+
retirement reasons, and `split.yml` goals, claims, questions and answers. Verified against
|
|
826
|
+
**both** parsers behind the runtime seam. Existing files do not churn: over every code point
|
|
827
|
+
from U+0020 to U+FFFF the new escaping and the old produce identical bytes (63,456 checked, 0
|
|
828
|
+
differ), so only the values that were already corrupt change shape.
|
|
829
|
+
- **A file already broken this way heals itself on load.** `parseYamlRepairing` (`core/yaml.ts`)
|
|
830
|
+
re-escapes raw control characters trapped inside a double-quoted scalar, re-parses, and accepts
|
|
831
|
+
the result only if it parses — otherwise the parser's ORIGINAL error is thrown, because a
|
|
832
|
+
repair that cannot be verified is not offered. `RunStore.open` then rewrites the mended file
|
|
833
|
+
through the fixed emitter and says so on stderr. A one-time hand repair was never enough: the
|
|
834
|
+
old emitter re-corrupted the file on the next save, so `emit(load(x))` had to be made stable.
|
|
835
|
+
- **Every state write keeps one step back.** `RunStore` and `FactsStore` had grown a
|
|
836
|
+
byte-identical private copy of temp-plus-rename each; both now call one
|
|
837
|
+
`core/fs/writeAtomic.ts`, which additionally copies the version it is about to replace to
|
|
838
|
+
`<file>.bak`. Atomicity only ever guaranteed a WHOLE file, never a good one. The copy is taken
|
|
839
|
+
before the rename, so the live file is never absent for an instant and a torn backup can only
|
|
840
|
+
ever cost a backup. `tldrx init` now adds `tldrx-work/*/*.bak` and `.tldrx/memory/*.bak` to the
|
|
841
|
+
managed `.gitignore` block.
|
|
842
|
+
- **A `run.yml` beyond mechanical repair fails honestly, and takes nothing else down with it.**
|
|
843
|
+
The error names the file, quotes the parser verbatim, says that every command on the run reads
|
|
844
|
+
that file first, and points at `run.yml.bak` — while stating plainly that using it is a MANUAL
|
|
845
|
+
decision tldrx will not make. Separately, one corrupt `run.yml` used to throw a raw
|
|
846
|
+
`YAMLParseError` out of `buildModel` and kill `tldrx dashboard` for the whole workspace;
|
|
847
|
+
`loadRunResult` (`core/replay/loadRun.ts`) now distinguishes missing from unreadable, and the
|
|
848
|
+
dashboard lists the run as **unreadable** with the parse error beside it and renders every
|
|
849
|
+
other run as normal.
|
|
850
|
+
|
|
851
|
+
### Changed
|
|
852
|
+
|
|
853
|
+
- **The docs now say, at the top of both places a reader starts, that `run attend host` is a LOCK
|
|
854
|
+
and `run auto` is an ENGINE.** Grounding: on 2026-08-31 the framework's own author — who had read
|
|
855
|
+
the chapter — ran `tldrx run attend host <run>` expecting it to drive the whole run by itself,
|
|
856
|
+
and then asked whether `attend` and `auto` compose. They do not, and the code has always said so
|
|
857
|
+
(`runAuto.ts:108` refuses `run auto` on an attended run at exit `1`, before the event log is
|
|
858
|
+
opened; `runNext.ts:659` exits `4` on a bare `next` and names the `--prepare` command). The docs
|
|
859
|
+
took too long to say it.
|
|
860
|
+
- **README gains "Trying it: three ways to run"**, immediately after Quick start: a three-row
|
|
861
|
+
table of who executes each turn, what a turn costs, and where each mode stops; one scenario
|
|
862
|
+
line each (`run auto` for a small run you would watch anyway and for CI/cron — the only mode
|
|
863
|
+
with no session behind it; `attend host` when a session is already open and cost or quality
|
|
864
|
+
matters; `attend host` + a mandate for overnight); and the two-command recipe with a verbatim
|
|
865
|
+
example **mandate prompt**.
|
|
866
|
+
- **`docs/guide/10-unattended-mode.md` leads with the same disambiguation** — a blockquoted
|
|
867
|
+
lock-vs-engine table above the chapter's opening paragraph, so a skimmer cannot make that
|
|
868
|
+
mistake — and gains a `### The mandate` section carrying the prompt verbatim, tying its four
|
|
869
|
+
legitimate interrupts back to the `questions` / `budget-event` / `boundary` fallthroughs the
|
|
870
|
+
framework already enforces, and to the fact that no `git push` wrapper exists in the Build
|
|
871
|
+
executor (`src/core/build/git.ts:13`) and the developer prompt says "Do not push"
|
|
872
|
+
(`src/core/build/prompts.ts:180`).
|
|
873
|
+
- **`tldrx run --help` says it too.** `run` had notes for `status`, `estimate`, `unlock` and
|
|
874
|
+
`cancel` and none for the pair that actually confuses people. It now leads with one note per
|
|
875
|
+
mode — "a LOCK, not an engine" / "an ENGINE, not a lock", each naming the other's refusal — and
|
|
876
|
+
the `<host|--none>` argument line says the framework will not spawn on the run again. Help text
|
|
877
|
+
only; no behaviour, no flag and no exit code moved.
|
|
878
|
+
|
|
879
|
+
- **`tldrx run estimate` is remaining-work aware (#21).** It priced the next stage from token medians while
|
|
880
|
+
the budget brake separately computed what that stage still had to pay for — two models, one question, and
|
|
881
|
+
the one people read was the one that never shrank. A Build stage with five of six stories done was still
|
|
882
|
+
quoted the number the Plan wrote before any of them ran, which is the figure that made a pilot operator
|
|
883
|
+
move money twice for work the run could already afford.
|
|
884
|
+
- It now calls **the same `remainingWork()`** the brake and `budget show`'s `est.` column call, with the
|
|
885
|
+
same inputs, and reports it beside the token estimate: done stories excluded, blocked ones named, the
|
|
886
|
+
arithmetic shown. A test asserts the two numbers are identical rather than merely similar.
|
|
887
|
+
- It also rolls the run up: `still to run: N stage(s) … $X priced`, with terminal stages excluded and the
|
|
888
|
+
cursor stage narrowed by the plan when the plan knew better. `--json` carries both as `remaining` and
|
|
889
|
+
`runRemaining`.
|
|
890
|
+
- The token half is untouched. The input side is still measured off the same assembly `next` builds, and
|
|
891
|
+
the cache/output medians still say which sample they came from — that half was never the complaint.
|
|
892
|
+
|
|
893
|
+
## 0.3.1 — 2026-08-31
|
|
894
|
+
|
|
895
|
+
**Unattended mode.** Twelve of the entries below are one feature: a run a **host session**
|
|
896
|
+
drives end to end, and a gate an **agent** may close over a check it wrote down. `attended_by: host`
|
|
897
|
+
stops the framework spawning on a run at all; `economy: host-tokens` stops a ceiling that is not
|
|
898
|
+
dollars from buying a metered spawn; the dispatch-notes slot gives the host the one place to add
|
|
899
|
+
what the bundle cannot know; `next --prepare/--commit --review` makes the Build reviewer the
|
|
900
|
+
second delegable role, so one review is done once; the `fixlist` verdict gives a review that
|
|
901
|
+
SIGNS somewhere to put its findings, for one bounded round that costs no attempt;
|
|
902
|
+
`gates_policy: agent` closes a gate on the seven auto conditions **plus** a boundary check, a
|
|
903
|
+
budget-event check and a validated evidence note, and falls through to a person on a question, a
|
|
904
|
+
moved ceiling, work nobody scoped, or its own refusal — rendered as a decision card rather than
|
|
905
|
+
a dashboard. Three smaller pieces stop a turn being wasted before it starts: `preconditions:`
|
|
906
|
+
on a stage, a story branch fast-forwarded onto its epic before dispatch, and a budget brake that
|
|
907
|
+
counts the work that is LEFT rather than the price the stage was written at. Two measurements
|
|
908
|
+
from 2026-08-30 are the whole argument: **$9.95** of spawns that died on caps a Plan agent had
|
|
909
|
+
priced in host tokens, and a framework reviewer that spawned beside a host already reading the
|
|
910
|
+
same diff. New chapter: `docs/guide/10-unattended-mode.md`. Every part is additive — a run with
|
|
911
|
+
none of these behaves byte-identically to the release before them.
|
|
912
|
+
|
|
913
|
+
### Added
|
|
914
|
+
|
|
915
|
+
- **A story branch that has fallen behind its epic is fast-forwarded before a developer is
|
|
916
|
+
dispatched onto it.** Measured 2026-08-30 on `260830-tenancy-identity-customers`: S3 was
|
|
917
|
+
reopened, `story reopen` keeps its branch by design, and that branch still sat at the S1-era
|
|
918
|
+
epic tip while the epic had since gained S2 and S5. S3's handlers needed S2's contract, so a
|
|
919
|
+
dispatch on that base would not have compiled. The host fast-forwarded by hand before
|
|
920
|
+
dispatching. That is the one case this automates.
|
|
921
|
+
|
|
922
|
+
```
|
|
923
|
+
· S3: fast-forwarded `story/260830-tenancy/S3` to `epic/tenancy` — 2 commit(s), b5a2474 → ae9c8dd
|
|
924
|
+
```
|
|
925
|
+
- **Where.** Inside `openStory`, which is the one place a story worktree is opened, and only
|
|
926
|
+
on the two openings that are about to put a DEVELOPER on the branch: the headless pipeline
|
|
927
|
+
and `tldrx next --prepare`. The review openings (`--prepare --review`, `--commit --review`,
|
|
928
|
+
an errored review re-run) and `--commit` measure nothing and move nothing — a fast-forward
|
|
929
|
+
there would drag other stories' commits onto a branch whose whole meaning is "what this
|
|
930
|
+
story built", for a base nobody is about to compile against.
|
|
931
|
+
- **The requeue case, which fires far more often than the reopen one.** A `changes` verdict
|
|
932
|
+
merges the story into its epic and then hands it a second attempt; before this, attempt 2
|
|
933
|
+
was dispatched onto attempt 1's base. It now starts on the current epic tip.
|
|
934
|
+
- **A diverged branch is warned about, never resolved.** Commits on both sides is the second
|
|
935
|
+
live case — a dead spawn's partial commit on a stale base, where no fast-forward existed
|
|
936
|
+
and the host preserved the partial on a backup branch and re-pointed the story branch by
|
|
937
|
+
hand. Which of two histories survives is a decision, so the framework does not make it: it
|
|
938
|
+
names both counts, both shas, and the two options, changes nothing, and lets the dispatch
|
|
939
|
+
proceed on the old base — saying, in as many words, which base that is.
|
|
940
|
+
- **A dirty worktree is left alone**, whatever the topology says. It is the operator's.
|
|
941
|
+
- **Never a rebase.** Rewriting a branch a developer has already committed to is the class of
|
|
942
|
+
move the run-id-in-branch-name fix (2026-08-29 audit §B) exists to prevent. The only write
|
|
943
|
+
is `git merge --ff-only`, which refuses rather than inventing a merge commit. Measured
|
|
944
|
+
2026-08-31 against a real repository, which is what the design asked to verify before
|
|
945
|
+
building: blocked by a file in the way it exits non-zero and leaves HEAD and the file
|
|
946
|
+
exactly as they were — atomic-or-nothing, so a failed fast-forward needs no repair, only a
|
|
947
|
+
line saying it did not happen.
|
|
948
|
+
- **`story.base_fastforwarded`** joins the closed §2.9 event set — the only event in it that
|
|
949
|
+
records tldrx moving a ref. It carries `story`, `repo`, `branch`, `base`, `from`, `to` and
|
|
950
|
+
`commits`, `tldrx replay` narrates it, and it is appended ONLY when the ref actually moved:
|
|
951
|
+
a divergent or dirty branch produces a warning and no event, because nothing happened.
|
|
952
|
+
- **An up-to-date branch is silent** and emits nothing, so a run with nothing to say about
|
|
953
|
+
its bases is what it was before.
|
|
954
|
+
- `tldrx story reopen` is unchanged: it still runs no git command, spends nothing and touches
|
|
955
|
+
no branch. The detection belongs where a worktree is being opened anyway and where the
|
|
956
|
+
operator is about to dispatch.
|
|
957
|
+
|
|
958
|
+
- **Decision cards — the shape an interrupt takes when a run stops for a person.** Measured
|
|
959
|
+
2026-08-30: an unattended run stopped on two owner questions, and the host did NOT show the
|
|
960
|
+
owner the dashboard or the `2 open question(s) in 01-what/questions.md` line the framework
|
|
961
|
+
actually prints. It hand-composed, in chat, the question, the options and a recommendation
|
|
962
|
+
with one line of why. The owner answered both in seconds. The card is what made the
|
|
963
|
+
interrupt cheap; hand-composing it is what the framework was making the host pay for.
|
|
964
|
+
|
|
965
|
+
```
|
|
966
|
+
DECISION — 260830-tenancy · 01-what/what
|
|
967
|
+
Q2 · Should an existing customer's tenant be inferred or asked for?
|
|
968
|
+
Why asked: no tenant column on the customer aggregate [src: absent:api:src/.../Customer.cs]
|
|
969
|
+
A) infer from the invoice email domain — no new UI, wrong for resellers
|
|
970
|
+
B) ask once at first login — one screen, correct for everyone
|
|
971
|
+
C) other — write it below
|
|
972
|
+
Recommends B — one screen, correct for everyone [src: 01-what/handoff.md:22]
|
|
973
|
+
tldrx answer Q2 "…" --run 260830-tenancy
|
|
974
|
+
```
|
|
975
|
+
- **Pure rendering of things that already exist.** The question, its `Why asked:` line and
|
|
976
|
+
its lettered options come out of `questions.md` through the **§2.7 parser** — the
|
|
977
|
+
questions grammar is not touched, and a block the parser cannot read does not appear on a
|
|
978
|
+
card any more than it appears anywhere else. The `Recommends` line comes out of the
|
|
979
|
+
evidence note's optional `recommend: [{q, option, why, src}]` array, which the evidence
|
|
980
|
+
grammar already validates.
|
|
981
|
+
- **A question with no recommendation gets no line.** Never a manufactured one and never a
|
|
982
|
+
placeholder: the whole value of that line is that an agent stood behind it with a
|
|
983
|
+
citation.
|
|
984
|
+
- **One renderer, three surfaces.** `tldrx run auto --gate-agent` at the stop;
|
|
985
|
+
`tldrx next`'s agent-gate fallthrough, where the card is **appended** to the fallthrough
|
|
986
|
+
list so nothing that reads those lines today loses a byte; and `tldrx status`, where a run
|
|
987
|
+
waiting on answers now shows the card rather than `open questions: Q1, Q2`.
|
|
988
|
+
- **A card per fallthrough kind.** Budget and boundary get their own card over the same
|
|
989
|
+
frame — the measured fact, then the commands (`widen the scope …` / `approve` / `reject`
|
|
990
|
+
for a boundary; the phase's two numbers plus `budget show` for a budget event) — and every
|
|
991
|
+
other reason an agent gate fell through is carried as one gate card naming its reasons.
|
|
992
|
+
- **`--gate-agent` is rendering only.** It does not upgrade any stage to
|
|
993
|
+
`gates_policy: agent`: a run keeps the policy it was opened with, and a flag that could
|
|
994
|
+
raise one at stop time would make the frozen policy decorative. On an
|
|
995
|
+
`attended_by: host` run it changes nothing — `run auto` is still refused at exit `1`
|
|
996
|
+
before the event log is opened, and nothing spawns.
|
|
997
|
+
- **Nothing else moves.** `tldrx answer`, `questions.md`, the live dashboard and every exit
|
|
998
|
+
code are unchanged, and `run auto` without the flag prints exactly the block it always did.
|
|
999
|
+
- **The budget brake counts the work that is LEFT, not the price the stage was written at.**
|
|
1000
|
+
Measured 2026-08-31 on `260830-tenancy-identity-customers`: four of seven stories done, one
|
|
1001
|
+
mid-attempt-2, two blocked, and the entire remaining metered cost a developer share and a
|
|
1002
|
+
reviewer floor — **$2.50**. The brake compared the phase's remaining dollars against
|
|
1003
|
+
`stage.budget_usd`, **$18.00**, a number written before a single story ran and never
|
|
1004
|
+
revised. It refused the stage twice and the host ran `budget raise --take-from` twice, for
|
|
1005
|
+
money nothing was going to spend.
|
|
1006
|
+
|
|
1007
|
+
For a Build stage with a plan on disk, `tldrx next`'s refusal, the `budget-gate` hook and
|
|
1008
|
+
`tldrx budget show`'s `est.` column now all use one figure computed by one function: `Σ`
|
|
1009
|
+
over the unsettled stories of the caps the executor would actually hand out — the
|
|
1010
|
+
`03-plan/budget.yml` price through the same scale/share arithmetic, the developer and
|
|
1011
|
+
reviewer shares, the `$1.00` reviewer floor, and the attempts each story has left.
|
|
1012
|
+
- **The refusal shows its arithmetic**: `remaining work: S4 dev $1.50 + reviewer $1.00 =
|
|
1013
|
+
$2.50`, under a line naming how many stories are done and what the stage's static
|
|
1014
|
+
estimate was. A number an operator cannot take apart is one they cannot argue with, and
|
|
1015
|
+
`$18.00` cited nothing.
|
|
1016
|
+
- **`blocked` costs $0.00**, and the blocked ids are named rather than quietly dropped: the
|
|
1017
|
+
executor dispatches a blocked story only after `tldrx story reopen`, which is a human
|
|
1018
|
+
decision and which legitimately raises the figure again.
|
|
1019
|
+
- **A story at `review` has already paid the developer turn under review.** Only a `changes`
|
|
1020
|
+
verdict buys another one.
|
|
1021
|
+
- **Under `economy: host-tokens` the developer turns are $0.00** — the host session pays for
|
|
1022
|
+
them — while the reviewer floors stay, because outside attended mode `reviewAndSettle`
|
|
1023
|
+
still spawns a metered reviewer and that floor is real money.
|
|
1024
|
+
- **It can only NARROW.** The figure is capped at `stage.budget_usd`, so this brake can
|
|
1025
|
+
never refuse more often than it did before; the reviewer floor can otherwise lift a naive
|
|
1026
|
+
sum past the ceiling. Asserted in both directions, across a spread of plan shapes, because
|
|
1027
|
+
a brake that loosened by accident is the failure to fear here.
|
|
1028
|
+
- **`budget.blocked` gains `estimate_basis: plan|static`** and, on the plan basis,
|
|
1029
|
+
`static_estimate_usd`, `stories_done` and `stories_total`.
|
|
1030
|
+
- **With no plan on disk, and outside Build, every path is byte-identical**, wording
|
|
1031
|
+
included: the estimate is `budget_usd` and the message still reads `the stage estimate
|
|
1032
|
+
is $X`.
|
|
1033
|
+
|
|
1034
|
+
- **`preconditions:` on a stage — the check that runs before the money does.** A stage may
|
|
1035
|
+
declare operational facts that must hold before it is worth dispatching at all:
|
|
1036
|
+
|
|
1037
|
+
```yaml
|
|
1038
|
+
preconditions:
|
|
1039
|
+
- {id: docker, repo: api, command: "docker compose ps", expect_exit: 0}
|
|
1040
|
+
```
|
|
1041
|
+
|
|
1042
|
+
The grounding is measured, 2026-08-30: before dispatching a Build story the host checked
|
|
1043
|
+
the Docker daemon and the .NET SDK **by hand**, because a story has two attempts, an agent
|
|
1044
|
+
cannot debug its way out of a daemon that is down, and the whole turn would have been spent
|
|
1045
|
+
proving it. That check took about a second and protected an attempt worth dollars.
|
|
1046
|
+
- **Same allowlist rule as a `cmd` check and a story's `` ```dod `` block — and now literally the
|
|
1047
|
+
same function.** Only a command byte-equal to one `.tldrx/workspace.yml` declares runs,
|
|
1048
|
+
argv-split, never through a shell. The comparison and both refusal sentences moved to
|
|
1049
|
+
`schemas/commandAllowlist.ts`, so the three sites can no longer drift into three readings
|
|
1050
|
+
of one rule. It is enforced **at load**: a stage naming an undeclared command never
|
|
1051
|
+
becomes a runnable stage, so `tldrx run new` over it refuses too.
|
|
1052
|
+
- **Red ⇒ refused, exit `2`, having spent nothing.** The id and the command's own exit code
|
|
1053
|
+
are named, the stage is left exactly where it was (`ready`), no bundle is written and
|
|
1054
|
+
nothing is spawned. The list stops at the first red one.
|
|
1055
|
+
- **`--prepare` runs them no less than headless** — a bundle written for a host whose Docker
|
|
1056
|
+
is down is the same wasted attempt as a spawn into one. `--commit` never runs them: it
|
|
1057
|
+
settles a turn that already happened.
|
|
1058
|
+
- **Every run is on the record**: one `check.passed` / `check.failed` event with
|
|
1059
|
+
`kind: precondition`, carrying the repo, the command, the exit code and the duration, and
|
|
1060
|
+
one operator line — `· precondition: docker compose ps → exit 0 (1.2s)`.
|
|
1061
|
+
- **A stage that declares none is byte-identical**: no event, no line, no shipped stage file
|
|
1062
|
+
changed. `[assumption]` — per stage, not per story; a per-story precondition is a real
|
|
1063
|
+
want and is deliberately not designed here.
|
|
1064
|
+
- **The `fixlist` verdict, its artifact and its router — the review that SIGNS and still has
|
|
1065
|
+
findings.** Measured 2026-08-31, driving `260830-tenancy-identity-customers` by hand: the
|
|
1066
|
+
reviewer signed story S5 — every acceptance criterion met, zero scope violations — and in the
|
|
1067
|
+
same breath named three real correctness/security defects the criteria never covered (a
|
|
1068
|
+
concurrent double-confirm minting two sessions, a non-atomic confirm, a false security comment
|
|
1069
|
+
beside a non-constant-time compare). S1 and S3 went the same way that night. Binary
|
|
1070
|
+
`approve`/`changes` has nowhere to put those: `approve` throws them away, `changes` spends the
|
|
1071
|
+
story's one requeue on a diff nobody faulted. So all three loops were run in chat — number the
|
|
1072
|
+
findings, decide fix-now vs defer-with-log, route them to the author, re-verify — and none of
|
|
1073
|
+
it reached a file. This is that loop, as a verdict and an artifact.
|
|
1074
|
+
- **`fixlist` settles the story at `review` and spends NO attempt.** The requeue counter counts
|
|
1075
|
+
verdicts that FAULTED the diff, and a signature is not one. `04-build/fixlist/<story>-<n>.md`
|
|
1076
|
+
is written beside it by the EXECUTOR, never by the reviewer — which holds no write tool, the
|
|
1077
|
+
same reason the review log is written there. Numbered `## <n> · <finding> [<severity>]`
|
|
1078
|
+
sections, each with `Where:`, `Disposition:` and `Resolved:`.
|
|
1079
|
+
- **A disposition ROUTES a finding; `Resolved:` CLOSES it.** Two questions, two fields, because
|
|
1080
|
+
one field cannot answer the first once the second is true. `fix-now` · `defer-with-log` ·
|
|
1081
|
+
`refuted` · `out-of-scope`, and **`refuted` must carry an `[src: …]`** in its `where` or
|
|
1082
|
+
`detail`, through the §2.8 grammar and the §2.8 parser — a reviewer's verdict is a claim like
|
|
1083
|
+
every other, and that night's host disproved one by grepping both sides before acting on it.
|
|
1084
|
+
A fix list with an uncited `refuted` is refused whole and the verdict falls to `changes`.
|
|
1085
|
+
- **The router: `tldrx next --prepare --fixlist <path>`.** The open findings land under
|
|
1086
|
+
`## Fix list` in the DEVELOPER's prompt, numbered, with their `Do NOT` lines verbatim — a
|
|
1087
|
+
bound the reviewer put on a fix is worth as much as the fix. `pending.json` gains
|
|
1088
|
+
`fixlist: {path, round, findings, open}` and `resume_session`, the prior turn's `session_id`,
|
|
1089
|
+
so the host can resume that sub-agent rather than pay to rebuild its context. **The framework
|
|
1090
|
+
resumes nothing itself** — `spawnAgent` has no `--resume` — so the bundle carries the fix list
|
|
1091
|
+
and the merged commit and hands the id back to the party that can act on it. Omit the flag and
|
|
1092
|
+
the latest still-open round is carried by itself, the same courtesy `--prepare` already
|
|
1093
|
+
extends to a story waiting on a review.
|
|
1094
|
+
- **One round per story, and the second is refused out loud.** A free round that could be taken
|
|
1095
|
+
twice is a story that never has to settle. A second `fixlist` is read as `changes` — which
|
|
1096
|
+
costs the attempt the first one did not — the refusal names the round already on disk, and the
|
|
1097
|
+
SECOND reviewer's prompt withdraws the verdict rather than offering one the executor would
|
|
1098
|
+
then refuse. `story reopen` resets the count with every other one in the review ledger.
|
|
1099
|
+
- **A story cannot settle `done` over an open `fix-now`.** An `approve` there settles `blocked`
|
|
1100
|
+
and the reason names the file, the finding's number and its heading, plus the two ways to
|
|
1101
|
+
close it. The check reads the FILE, not the envelope that produced it: the file is the state,
|
|
1102
|
+
and a host closes a finding by writing `Resolved: yes` in it or re-routing its `Disposition:`.
|
|
1103
|
+
That edit is the host's — §B.2's third role — because the author works in a story worktree of
|
|
1104
|
+
another repo and its own prompt forbids writing outside it.
|
|
1105
|
+
- **Fail-closed, unchanged and asserted.** A `verdict: "fixlist"` whose `fixlist[]` is missing,
|
|
1106
|
+
empty or unreadable is `changes`, never a free round and never `approve`. Both economies reach
|
|
1107
|
+
the same code: the host writes the envelope into the review bundle, or a spawned reviewer
|
|
1108
|
+
returns it — `REVIEW_SCHEMA` gained the verdict and the optional array, and `parseReview`
|
|
1109
|
+
narrows both.
|
|
1110
|
+
- `defer-with-log` findings are appended to `retro.md`'s `## Build feedback` as the artifact is
|
|
1111
|
+
written — the existing second writer with its existing verbatim dedup — so a deferred defect
|
|
1112
|
+
reaches the owner through a channel that already exists rather than a new one.
|
|
1113
|
+
|
|
1114
|
+
- **`tldrx next --prepare --review` / `--commit --review` — the reviewer is the second
|
|
1115
|
+
delegable role.** A Build story has two sub-agents and only the developer was ever
|
|
1116
|
+
delegable; the reviewer was the FRAMEWORK's spawn in both modes, which on a host-driven
|
|
1117
|
+
run buys a second reading of a diff the host is already reading, and a bill nobody
|
|
1118
|
+
budgeted. Now it rides the same handshake, one directory down:
|
|
1119
|
+
`.agent/<stage>/<story>/review/{prompt.md,pending.json,result.json}` — nested so a
|
|
1120
|
+
reviewer bundle can never be read as a developer one.
|
|
1121
|
+
- **`--prepare --review` writes the bundle and spawns nothing.** `prompt.md` is what a
|
|
1122
|
+
spawned reviewer would have been sent, from the same renderer. `pending.json` carries
|
|
1123
|
+
`role: reviewer`, `result_schema` (the reviewer's `--json-schema` envelope, verbatim,
|
|
1124
|
+
so the host needs no source to know the shape), and a `review:` block with the diff
|
|
1125
|
+
command, the merged commit, the attempt and the **DoD results recovered from
|
|
1126
|
+
`events.jsonl`** — the DoD is not re-run, and the prompt says so.
|
|
1127
|
+
- **`--commit --review` settles it through the existing seam.** The envelope goes through
|
|
1128
|
+
the same `parseReview` with the same fail-closed rule (unreadable ⇒ `changes`, never
|
|
1129
|
+
`approve`) and the same `reviewAndSettle`: `approve` ⇒ `done` with its evidence,
|
|
1130
|
+
`changes` ⇒ one requeue then `blocked`, `MAX_ATTEMPTS` and the requeue counter
|
|
1131
|
+
untouched. A host that never writes `result.json` has produced no verdict and spends
|
|
1132
|
+
no attempt.
|
|
1133
|
+
- **The trail says whose review it was.** No `agent.spawned`; a `task.started` with
|
|
1134
|
+
`role: reviewer, mode: prepare` instead. The `check: review` event carries
|
|
1135
|
+
`source: host` (written only for a host review, so the spawned path's payload is
|
|
1136
|
+
unchanged byte for byte), and the task row is `cost_usd: null, metered: false` unless
|
|
1137
|
+
the envelope declares `cost_usd` / `tokens`.
|
|
1138
|
+
- **On `attended_by: host` the framework never calls `spawnReviewer` at all.** Half B
|
|
1139
|
+
merges the story and hands the review over, so a full attended story cycle emits zero
|
|
1140
|
+
`agent.spawned`. Outside attended mode `--review` is opt-in and the headless reviewer
|
|
1141
|
+
is unchanged.
|
|
1142
|
+
|
|
1143
|
+
### Changed
|
|
1144
|
+
|
|
1145
|
+
- **`tldrx next --prepare` on a story awaiting review now writes the reviewer bundle
|
|
1146
|
+
instead of spawning a reviewer.** It used to spawn a metered one — which is the single
|
|
1147
|
+
thing `--prepare` exists not to do. Measured 2026-08-31 on the live
|
|
1148
|
+
`260830-tenancy-identity-customers`: story S3's reviewer died at its $1.00 cap, the
|
|
1149
|
+
story parked at `review`, and the `--prepare` that was supposed to rescue it spawned a
|
|
1150
|
+
replacement that a two-minute host timeout then killed mid-read. The story is still
|
|
1151
|
+
parked at `review` afterwards, its attempt still unspent, and the verdict is the host's
|
|
1152
|
+
to write. Headless `tldrx next` still re-runs the review by spawning, unchanged.
|
|
1153
|
+
- **`tldrx next`'s `attended_by: host` refusal names `--commit --review`** when the stage
|
|
1154
|
+
is holding a reviewer bundle. It named `--commit`, which is the wrong half: that door
|
|
1155
|
+
reads the DEVELOPER's `result.json` and re-runs a pipeline that has already merged.
|
|
1156
|
+
- **`--discard-pending` bins the reviewer bundle too**, alongside the developer bundles it
|
|
1157
|
+
already binned — a stale review `result.json` would otherwise be read by the next
|
|
1158
|
+
`--commit --review` as a verdict on work it never saw.
|
|
1159
|
+
|
|
1160
|
+
- **`tldrx init` says what it is doing while it does it** — a live line per step, in the
|
|
1161
|
+
same `--ui scene|compact|plain|off` view family as the agent progress view, on stderr.
|
|
1162
|
+
It used to print NOTHING until it was finished. Measured 2026-08-30 on a five-repo
|
|
1163
|
+
workspace: **36.0 s of total silence** with the default `--provider auto` against
|
|
1164
|
+
**1.3 s** with `--provider static` — so ~97% of the wait is `graphify update` running
|
|
1165
|
+
once per repo inside `buildMap`, and the command looked hung for all of it.
|
|
1166
|
+
- **Ten steps announce themselves**: detecting repos, building the code map, writing
|
|
1167
|
+
`workspace.yml`, planning the interview, seeding experts, reading conventions, writing
|
|
1168
|
+
`process.yml`/`facts.yml`, `init-questions.md`, `init-handoff.md`, and the `.gitignore`
|
|
1169
|
+
+ `CLAUDE.md` blocks. `--mcp` adds an eleventh, because it health-checks every server.
|
|
1170
|
+
- **The slow ones name the repo they are inside.** `detecting repos` reports each repo
|
|
1171
|
+
with its stack, confidence and default branch as detection finishes it; the map step
|
|
1172
|
+
reports `<repo> — 6 documents via graphify` per repo. The wait is now legible instead
|
|
1173
|
+
of merely long.
|
|
1174
|
+
- **A terminal gets a spinner, colour and an in-place rewrite; a pipe gets plain lines.**
|
|
1175
|
+
A finished step is printed once and never touched again, so it survives in scrollback
|
|
1176
|
+
after the command exits — a step list is a HISTORY, not a picture of a moment, which is
|
|
1177
|
+
why this is not `ui/driver.ts` with a different renderer. In `plain` a step still open
|
|
1178
|
+
after 5 s says `still <label> — 12 s`, because there is no spinner there to prove the
|
|
1179
|
+
process is alive.
|
|
1180
|
+
- **A schoolhouse** (`core/ui/campus.ts`) is painted above the steps in `scene` mode,
|
|
1181
|
+
drawn in the same hand as the classroom the agent view renders — `init` is the survey
|
|
1182
|
+
that happens before the school opens.
|
|
1183
|
+
- **`--quiet`** turns the live view off and keeps the report. **`--ui <mode>`** works on
|
|
1184
|
+
`init` exactly as it does on `next`, `run auto` and `expert train`, and a bad value is
|
|
1185
|
+
a usage error raised before any work is done.
|
|
1186
|
+
- **`core/ui/color.ts`** — the framework's first ANSI palette, resolved per STREAM rather
|
|
1187
|
+
than per process. `tldrx init > report.txt` on a terminal has a piped stdout and a TTY
|
|
1188
|
+
stderr: the live lines are still worth colouring and the file must still be plain text.
|
|
1189
|
+
`palette(false)` is the identity for every ink, so a renderer never branches on colour
|
|
1190
|
+
and the uncoloured path stays byte-for-byte deterministic in a test. `FORCE_COLOR` beats
|
|
1191
|
+
`NO_COLOR` beats `CI` beats the stream.
|
|
1192
|
+
|
|
1193
|
+
- **The dispatch-notes slot** — `.agent/<stage>/dispatch-notes.md`, and for a Build story
|
|
1194
|
+
`.agent/<stage>/<story>/dispatch-notes.md`: the one place a HOST can add context to a
|
|
1195
|
+
prompt the framework generated. Measured over one full run of
|
|
1196
|
+
`260830-tenancy-identity-customers`, 2026-08-30, EVERY stage needed host-added context the
|
|
1197
|
+
bundle lacked — a deferred decision at What, non-inlined seed docs and a staleness warning
|
|
1198
|
+
at How, the owner's answers at Plan, "Docker is up" at Build — and the host had exactly two
|
|
1199
|
+
places to put any of it, neither of which is one: `stage.md` is the framework's file,
|
|
1200
|
+
shared by every run of that workflow, and an edit to `prompt.md` is thrown away by the next
|
|
1201
|
+
`--prepare`.
|
|
1202
|
+
- **Rendered under `## Dispatch notes`, between `## Inputs` and `## Previous attempt`.**
|
|
1203
|
+
Behind the expert blocks on purpose: the slot is the most volatile thing in the document
|
|
1204
|
+
— a human writes it between one cycle and the next — and a per-cycle file ahead of the
|
|
1205
|
+
largest stable section would pay the cache-WRITE price on every stage. The same position
|
|
1206
|
+
in the Build developer prompt, directly under `## Inputs`, because that is where the
|
|
1207
|
+
brief ends and `## Investigate` step 1 tells the developer the files above ARE the brief.
|
|
1208
|
+
- **Absent ⇒ nothing changes, byte for byte.** No section, no `dispatch_notes` key in
|
|
1209
|
+
`pending.json`, `0 B` in the context ledger. Asserted by adding the file, re-preparing,
|
|
1210
|
+
removing it, re-preparing, and comparing the two prompts byte for byte.
|
|
1211
|
+
- **Capped at 8 KB, and never free.** The stage's file and the story's file feed ONE slot,
|
|
1212
|
+
spent stage-file-first, so neither can quietly double the budget; the overflow is named
|
|
1213
|
+
in the prompt, on stdout, and in `pending.json`
|
|
1214
|
+
(`dispatch_notes: {bytes, truncated, max_bytes, sources[]}`). The rendered section's
|
|
1215
|
+
bytes are charged to the context ledger and count against `prompt_max_bytes` like
|
|
1216
|
+
everything else — asserted with a ceiling the prompt clears without notes and breaks
|
|
1217
|
+
with them. The byte cut never splits a character in half.
|
|
1218
|
+
- **Context, never configuration.** The framework does not parse it, does not substitute
|
|
1219
|
+
`{{placeholders}}` in it, does not require `[src: …]` tokens on it, and it cannot change
|
|
1220
|
+
a declared input, an output, a check or a cap. The section says all of that to the
|
|
1221
|
+
sub-agent in its own preamble, because a note that reads like an instruction is otherwise
|
|
1222
|
+
indistinguishable from the stage's own rules.
|
|
1223
|
+
- **Survives `--discard-pending`.** The flag bins `pending.json`, `result.json` and
|
|
1224
|
+
`result.raw.json`; the notes are an INPUT to the rendering that is about to be redone,
|
|
1225
|
+
not an output of the one being binned.
|
|
1226
|
+
- **Per-cycle scratch, deliberately.** `.agent/` is gitignored, and that is the whole
|
|
1227
|
+
point: a caveat that must outlive the cycle is a FACT, and `.tldrx/memory/facts.yml` is
|
|
1228
|
+
the durable channel that already reaches every prompt with attribution behind it. Two
|
|
1229
|
+
durable channels for the same thing would make neither authoritative.
|
|
1230
|
+
- **`attended_by: host` — a run a host session drives, that the framework never spawns on.**
|
|
1231
|
+
One optional top-level key in `run.yml`, set at creation with `tldrx run new --attended-by host`
|
|
1232
|
+
or flipped later with `tldrx run attend host` / `tldrx run attend --none`. The finding it is
|
|
1233
|
+
built for is one sentence of field notes from 2026-08-30: a bare `tldrx next` on a Build stage
|
|
1234
|
+
runs the WHOLE remaining headless pipeline — every wave, every story, as paid spawns — when the
|
|
1235
|
+
host wanted one re-review. Six of six of those spawns then died on `Reached maximum budget` at
|
|
1236
|
+
caps a Plan agent had authored assuming host-billed sub-agents. $9.95, nothing delivered. The
|
|
1237
|
+
affordance was missing at the RUN level: `--prepare`/`--commit` is a decision per invocation,
|
|
1238
|
+
and nothing on the run could say "this one is being driven by a host session".
|
|
1239
|
+
- **A bare `tldrx next` exits `4` and names the exact command** the stage is waiting for —
|
|
1240
|
+
`--prepare` when it is ready, `--commit` when a bundle is already out. Four, not two: the run
|
|
1241
|
+
is not refusing the work, it is waiting on the host to take a turn, which is the same shape as
|
|
1242
|
+
waiting at a gate and the code `run auto` already stops cleanly on. The refusal is the first
|
|
1243
|
+
thing in `runStage` — ahead of the budget gate, ahead of reading an input, ahead of assembling
|
|
1244
|
+
a prompt — so nothing is billed and nothing is written.
|
|
1245
|
+
- **`--dry-run` is refused with it, and the message says why.** `--dry-run` is `mode: headless`:
|
|
1246
|
+
it spawns a real sub-agent and the turn is billed, and only the non-handoff FILES are reverted
|
|
1247
|
+
afterwards. That is measured, not read — one `agent.spawned`, one `agent.result`, the cost on
|
|
1248
|
+
the ledger. The CLI reference said "Spawns nothing, writes nothing" and a comment in
|
|
1249
|
+
`next.ts` said the same; both were wrong and both are corrected here.
|
|
1250
|
+
- **`tldrx run auto` is refused at exit `1`**, before the event log is opened, so nothing is
|
|
1251
|
+
written. A loop whose whole job is calling `next` headless has nothing to do on a run where
|
|
1252
|
+
`next` headless is a refusal.
|
|
1253
|
+
- **Three layers, because "nothing spawns" is a promise about money.** `runNext` refuses; every
|
|
1254
|
+
executor (`build`, `watch`) refuses a headless context with `refused: true`, so the stage goes
|
|
1255
|
+
back to `ready` rather than being marked failed; and `spawnAgent` itself throws while an
|
|
1256
|
+
attended run is in flight. The third is what makes "no run path can reach a spawn" a property
|
|
1257
|
+
rather than a claim about three `if`s — a fourth call site is always one merge away.
|
|
1258
|
+
- **`tldrx run attend` is deliberately small**: it sets one field, appends one `run.attended`
|
|
1259
|
+
event carrying the new value and the old, and touches no stage, no output, no branch and no
|
|
1260
|
+
money. `--none` REMOVES the key rather than blanking it, because `attended_by: null` is not a
|
|
1261
|
+
legal value. A direction is required and never guessed (exit `1`); setting what is already set
|
|
1262
|
+
appends nothing, since a decision nobody made does not belong in the log; a `done` or
|
|
1263
|
+
`cancelled` run is refused (exit `2`).
|
|
1264
|
+
- **`tldrx run status` prints `attended: host`** and the status line gains an `att` marker
|
|
1265
|
+
beside `auto:N` / `stale:N`, leading them because it is the one that changes what `tldrx next`
|
|
1266
|
+
will do. `--json` gains `attended_by`, appended after `unmetered_tasks` so every existing key
|
|
1267
|
+
keeps its position.
|
|
1268
|
+
- **Additive, and asserted as such.** Absent — which is every run.yml written before this — the
|
|
1269
|
+
framework may spawn and every path is what it was: the two-stage headless fixture's event
|
|
1270
|
+
sequence is asserted against the one captured from `main` at `dae1d07`, event for event, and
|
|
1271
|
+
an ordinary run.yml never mentions the key. A value the reader does not understand is a schema
|
|
1272
|
+
error rather than a silent downgrade to "spawn anyway"; `requireKeys` ignores unknown top-level
|
|
1273
|
+
keys, so an older binary reading a run.yml with `attended_by` still validates it — but it will
|
|
1274
|
+
DROP the key on its next save, since `emitRunYaml` only writes what it knows.
|
|
1275
|
+
- Out of scope on purpose: `tldrx expert train` and `tldrx seed triage --propose` spawn outside a
|
|
1276
|
+
run and are untouched. `attended_by` is a property of a run.
|
|
1277
|
+
- **`economy: metered-usd | host-tokens` on `budget.yml`** — a price gets a currency, and a
|
|
1278
|
+
headless spawn under a ceiling that is not money is refused before it spends. Measured
|
|
1279
|
+
2026-08-30 on `260830-tenancy-identity-customers`: the Plan agent priced the run assuming
|
|
1280
|
+
HOST-billed sub-agents — turns the host session pays for, which this process never meters
|
|
1281
|
+
and which are ~free to the run — and the executor then enforced those figures as dollar
|
|
1282
|
+
ceilings on METERED spawns. Six spawns of six died on `Reached maximum budget`, each
|
|
1283
|
+
having spent real money to get there: **$9.95**, for nothing. The money model was a single
|
|
1284
|
+
scalar with no unit on it and no way to say *"this number is not dollars."*
|
|
1285
|
+
- **One optional key, three places**: the run level of `budget.yml`, any `phases[]` entry
|
|
1286
|
+
of it (which overrides the run), and the root of `03-plan/budget.yml`, so a Plan agent
|
|
1287
|
+
can say which economy it was pricing in. Resolution is phase-then-run.
|
|
1288
|
+
- **`tldrx next` refuses a headless spawn on a `host-tokens` phase — exit 2, above prompt
|
|
1289
|
+
assembly, before a byte is written or a cent is spent.** The message names the number,
|
|
1290
|
+
the unit, and both ways out (`tldrx next --prepare`, or re-label and re-price the
|
|
1291
|
+
phase). `--prepare` / `--commit` are untouched: the in-session handshake is exactly
|
|
1292
|
+
where a host-billed turn belongs.
|
|
1293
|
+
- **The two are never converted into one another.** There is no exchange rate here and
|
|
1294
|
+
inventing one would be a guess about a price. The budget-gate hook does not deny on such
|
|
1295
|
+
a phase (there is no dollar ceiling to enforce, and it says so on stderr), the auto
|
|
1296
|
+
gate's money condition reads `n/a (host-tokens economy)` rather than comparing a spend
|
|
1297
|
+
in dollars to a ceiling in tokens, and `tldrx run estimate` prices the stage in TOKENS
|
|
1298
|
+
with no dollar figure at all.
|
|
1299
|
+
- **A `03-plan/budget.yml` priced in `host-tokens` contributes no story caps.** Its
|
|
1300
|
+
numbers are not dollars, so the Build executor falls back to the uniform share it used
|
|
1301
|
+
before plan prices were read at all — and says so on stderr, through the advisory seam
|
|
1302
|
+
that already existed for an unusable plan budget.
|
|
1303
|
+
- **An unknown value is REFUSED, never defaulted to dollars** — a unit nothing here
|
|
1304
|
+
understands is not one it may quietly read as money. An empty `economy:` key, and an
|
|
1305
|
+
absent one, both mean `metered-usd`.
|
|
1306
|
+
- **`tldrx budget raise` no longer erases what it rewrites past.** The label round-trips
|
|
1307
|
+
through the same emitter the raise goes through; a raise that dropped it would turn a
|
|
1308
|
+
token budget back into dollars silently, from the one command an operator reaches for
|
|
1309
|
+
when a ceiling binds.
|
|
1310
|
+
- **Absent label ⇒ byte-identical behaviour**, asserted: an unlabelled `budget.yml` emits
|
|
1311
|
+
no `economy:` line, an unlabelled headless stage still spawns, and every existing budget
|
|
1312
|
+
test passes untouched.
|
|
1313
|
+
|
|
1314
|
+
- **`tldrx story reopen <id> --note "<why>"`** — one Build story, given another run of
|
|
1315
|
+
developer attempts, by a person. The third verb of the family that landed 2026-08-30 and
|
|
1316
|
+
the only one a HUMAN signs: the other two stop the machine reading a transport failure
|
|
1317
|
+
as a judgement, and this one is for when the machine read the run correctly and the
|
|
1318
|
+
owner disagrees. Found on `260830-tenancy-identity-customers`, where story S3 sat
|
|
1319
|
+
`blocked` after two GENUINE `changes` verdicts (its developers ran and committed nothing;
|
|
1320
|
+
both reviewers correctly refused an empty diff) — no rescue applied and none should have,
|
|
1321
|
+
but S3 gates wave 3 and the owner had decided it ships. The only reopening verb was
|
|
1322
|
+
`tldrx reject --stage`, which acts on a STAGE, and hand-editing `run.yml` or a story file
|
|
1323
|
+
is forbidden by design.
|
|
1324
|
+
- **The note is required** — a reopen with no reason is not actionable — and one
|
|
1325
|
+
`story.reopened` is appended carrying the actor, the note, the status the story came
|
|
1326
|
+
from, its wave, and how many verdicts the closed run of attempts consumed.
|
|
1327
|
+
- **The story goes back to `todo` and its attempt counter restarts at 1 of 2.** Nothing
|
|
1328
|
+
is erased to make that true: `story.reopened` is a RESET BOUNDARY that
|
|
1329
|
+
`readReviewLedger` reads, so verdicts before it stop counting while every event stays
|
|
1330
|
+
in `events.jsonl` for `replay`, `cost` and `retro`. The full reset is the honest
|
|
1331
|
+
choice precisely because the history survives it — "you get two more turns" is what
|
|
1332
|
+
overruling a block means, and a half-reset would be a number nobody could explain from
|
|
1333
|
+
the record.
|
|
1334
|
+
- **It runs no agent, spends nothing, deletes nothing and refunds nothing.** The story's
|
|
1335
|
+
BRANCH is what carries the last developer's commits forward and it is untouched; the
|
|
1336
|
+
worktree is left exactly as the build left it (kept at `review`, already removed for a
|
|
1337
|
+
`blocked` story) and is reopened from the branch when the next turn needs it.
|
|
1338
|
+
- **It does not make the stage runnable, and does not pretend to.** Sending a stage back
|
|
1339
|
+
is `reject`'s own signed decision, so the output names the command that fits where the
|
|
1340
|
+
Build stage actually is: `tldrx next` when it is ready, `tldrx reject --note` at a
|
|
1341
|
+
pending gate, `tldrx reject --stage` over a signed one.
|
|
1342
|
+
- Refuses with exit 2, saying why: an id the plan does not have (naming the ones it
|
|
1343
|
+
does); a `done` story, because undoing finished work is a decision about the stage and
|
|
1344
|
+
belongs to `reject --stage`; a `todo` story, which is already pending; and a missing
|
|
1345
|
+
`--note`. An unknown run id is still exit 3.
|
|
1346
|
+
- `tldrx replay` narrates it (`story S3 REOPENED by alan — back to \`todo\` from
|
|
1347
|
+
\`blocked\` — "…"`), and the Build stage says so in one line, with the note, when it
|
|
1348
|
+
picks the story up. A reopened story is byte-identical on disk to a never-started one,
|
|
1349
|
+
and a narrative that showed two `changes` verdicts and then a third developer turn with
|
|
1350
|
+
nothing in between would read as the framework losing count.
|
|
1351
|
+
- **`--parallel <n>` on `tldrx next` and `tldrx run auto`** — how many stories of ONE
|
|
1352
|
+
build wave run at once. `waves.yml` already puts every dependency in an earlier wave, so
|
|
1353
|
+
a wave's stories are independent by construction. Also settable per scope as
|
|
1354
|
+
`build: {parallel: N}` at the top of a workflow, or per stage as `parallel:` in
|
|
1355
|
+
`stage.yml`; the flag beats the workflow, which beats the stage file. **The default is 1
|
|
1356
|
+
and at 1 the executor takes exactly the path it always did** — verified byte-identical on
|
|
1357
|
+
the event sequence against `main`, not asserted. Above 1 the wave runs in two halves:
|
|
1358
|
+
developer + DoD + commit concurrently, then merge + reviewer serially in the wave's
|
|
1359
|
+
LISTED order, so the epic branch reads the same whatever order the machine finished in.
|
|
1360
|
+
The reviewer half is serial for a reason and not only the merge: a reviewer reads
|
|
1361
|
+
`git diff <epic>...<story>`, whose merge base moves every time another story merges into
|
|
1362
|
+
that epic. A red story does not cancel its siblings, but the wave ends `failed` and the
|
|
1363
|
+
next wave does not start. Ctrl-C/SIGTERM kills every live child, not the first.
|
|
1364
|
+
Budgets are untouched: the stage ceiling was already divided by
|
|
1365
|
+
`stories x attempts x (developer + reviewer)`, so N at once costs what N in a row cost.
|
|
1366
|
+
- The live view gives each running story its own column — `S1 reading … · S2 $ dotnet
|
|
1367
|
+
test …` — in the scene, the compact one-liner and `--ui plain`. A lane leaves the line
|
|
1368
|
+
when its sub-agent finishes. With nothing parallel the view is what it always was.
|
|
1369
|
+
|
|
1370
|
+
- **`gates_policy: agent` — a gate an agent may close, over a check it wrote down.** The third
|
|
1371
|
+
answer to "who closes a gate", beside `human` (waits) and `auto` (the harness signs when seven
|
|
1372
|
+
measured conditions hold). Measured 2026-08-30 on `260830-tenancy-identity-customers`: the host
|
|
1373
|
+
ran a defined checklist at every gate and typed it into `approve --note "<free text>"`, where
|
|
1374
|
+
nothing validated it, `replay` could not render it, and `run.yml` recorded a person's name for a
|
|
1375
|
+
check a sub-agent had made. The evidence note (above) was the artefact; this is the gate.
|
|
1376
|
+
- **Strictly stronger than an auto gate, never a cheaper one.** Three things, not one: every
|
|
1377
|
+
one of the seven `auto` conditions unchanged and unweakened (including the `boundary`
|
|
1378
|
+
condition landed alongside it), PLUS no budget decision in this stage's window, PLUS an
|
|
1379
|
+
evidence note that parses, sources every bullet, and whose verdict is `sign`.
|
|
1380
|
+
- **The budget requirement is an EVENT, not an arithmetic.** A `budget.raised` or
|
|
1381
|
+
`budget.blocked` in `events.jsonl` at or after the stage's `started_at` falls the gate to a
|
|
1382
|
+
person even when the spend is comfortably under the ceiling. Condition 3 already compares
|
|
1383
|
+
numbers; what it cannot see is that somebody *raised* the ceiling to let this stage through,
|
|
1384
|
+
and a decision made to unblock a stage may not then be signed off by the machine that was
|
|
1385
|
+
blocked. Asserted in both directions on one fixture: the same gate closes without the event
|
|
1386
|
+
and falls through with it, with nothing else changed.
|
|
1387
|
+
- **The same door, so the trail reads the same.** A closing agent gate goes through `approve`:
|
|
1388
|
+
the checks are re-run off disk, `gate.by` records the note's `by:`, one ordinary
|
|
1389
|
+
`gate.approved` is appended, and the cursor advances. `AUTO_GATE_ACTOR` is untouched —
|
|
1390
|
+
`by: auto` still means "the facilitator closed it with no note but its own conditions".
|
|
1391
|
+
- **The note is COPIED into the run tree**, at `<phase>/gate-evidence/<stage>.md`, and that
|
|
1392
|
+
copy is what `gate.evidence.path` points at. `.agent/` is gitignored by spec §1, and a gate
|
|
1393
|
+
whose evidence lives only in a gitignored directory is a gate nobody can audit from a clone.
|
|
1394
|
+
A copy, not a move: the scratch original stays where the agent left it.
|
|
1395
|
+
- **Four fallthroughs are named in their own right**, because a person's next move differs for
|
|
1396
|
+
each: `questions` (a decision nobody has made), `budget-event` (a ceiling somebody moved),
|
|
1397
|
+
`boundary` (work nobody scoped) and `refusal` (the note's verdict is `refuse` or
|
|
1398
|
+
`sign-with-fixlist` — the agent doing its job, not failing at it). Any other failing condition
|
|
1399
|
+
reports as `condition`, a missing or broken note as `evidence`. Each is tested in isolation:
|
|
1400
|
+
a report that fired three at once would not answer "which of these stopped it", which is the
|
|
1401
|
+
first question anybody asks.
|
|
1402
|
+
- **`tldrx approve --as-agent [--evidence <path>]`** is the same decision taken by hand, and it
|
|
1403
|
+
splits the two refusals apart by exit code. **Exit 2** is "this note is broken" — fix the
|
|
1404
|
+
file, nothing was signed. **Exit 4** is "a person decides": the note parsed perfectly and its
|
|
1405
|
+
verdict is not `sign`. **Exit 1** is `--as-agent` on a stage whose policy is not `agent`: a
|
|
1406
|
+
run keeps the policy it was opened with, and a flag that could upgrade one at approve time
|
|
1407
|
+
would make the frozen policy decorative.
|
|
1408
|
+
- **A person may always overrule it.** A plain `tldrx approve` on an agent-gated stage works
|
|
1409
|
+
exactly as it does anywhere else, is recorded as the person, and writes no `evidence` key. An
|
|
1410
|
+
agent gate is one an agent MAY close, never one a person may not.
|
|
1411
|
+
- **`tldrx replay` renders the check**, not just the signature: who signed, how many files they
|
|
1412
|
+
read, how many citations they spot-checked and what those resolved to, how many touched paths
|
|
1413
|
+
they audited and how many were outside the surface, and the path to the note. Rendered from
|
|
1414
|
+
the note's FRONT MATTER and from `run.yml` — never from its prose, which would change the
|
|
1415
|
+
narrative every time somebody rephrased a sentence. A note that has gone missing is SAID to
|
|
1416
|
+
be missing rather than invented, and the counts `run.yml` recorded still stand.
|
|
1417
|
+
- **`--gates` gains a qualified form**: `--gates plan:agent,build:agent`. A bare entry still
|
|
1418
|
+
means `human`, so every invocation anybody has already typed means exactly what it meant. An
|
|
1419
|
+
unknown policy is its own usage error, distinct from an unknown stage.
|
|
1420
|
+
- **Additive, and asserted as such.** No shipped scope uses `agent`; it arrives via
|
|
1421
|
+
`--gates`, or a fork's own workflow file, and never by default. An absent `gates_policy`
|
|
1422
|
+
entry still reads as `human`. A gate with no `evidence` emits no key at all, so every
|
|
1423
|
+
`run.yml` written before this round-trips byte-for-byte through a save — asserted by
|
|
1424
|
+
comparing the emitter's output against the file on disk. The gate mapping never rejected an
|
|
1425
|
+
unknown key (measured against the reader that predates this), so a `run.yml` carrying
|
|
1426
|
+
`gate.evidence` still validates on an older binary; a `gates_policy` naming `agent` there
|
|
1427
|
+
fails loudly instead, which is the right failure — a policy the reader does not understand is
|
|
1428
|
+
not one it may downgrade to "sign it anyway".
|
|
1429
|
+
- **The emitter had to be extended, not worked around.** `emitRunYaml`'s `gate()` wrote exactly
|
|
1430
|
+
five keys as a flow mapping, so a sixth held in memory would have been dropped, silently, by
|
|
1431
|
+
the next save. 47 new tests.
|
|
1432
|
+
|
|
1433
|
+
- **The gate evidence note** — `.agent/<stage>/evidence.md`, plus `tldrx gate template` to
|
|
1434
|
+
write the blank form. This is the artefact half of the `agent` gate (design §A): a third
|
|
1435
|
+
answer to "who closes a gate", between `human` (waits) and `auto` (the harness signs when
|
|
1436
|
+
seven measured conditions hold). Measured 2026-08-30 on
|
|
1437
|
+
`260830-tenancy-identity-customers`: the host ran a defined checklist at every gate and
|
|
1438
|
+
typed `approve --note "<evidence>"` by hand, into a free-text field where nothing validated
|
|
1439
|
+
it and `replay` could not render it. There was no value meaning *"an agent checked it,
|
|
1440
|
+
showed its work, and is accountable for the check"*, so there was nowhere to put the check.
|
|
1441
|
+
- **Front matter is the machine half, the body is the human half** — the §2.13 story
|
|
1442
|
+
pattern, reused rather than reinvented. Required keys: `version gate role by at verdict
|
|
1443
|
+
read citations touches diff_vs_stories`; `caveats` and `recommend` are optional and
|
|
1444
|
+
default to `[]`. Four H2 sections in order — `Read` · `Citations checked` ·
|
|
1445
|
+
`Touches audited` · `Verdict` — each with at least one list item.
|
|
1446
|
+
- **Every bullet goes through the EXISTING §2.8 resolver.** Not a second grammar and not a
|
|
1447
|
+
second checker: `srcToken.ts` tokenizes and resolves, and the section rule is
|
|
1448
|
+
`handoff.ts`'s, lifted into a shared `validateSections` that `validateHandoff` now calls
|
|
1449
|
+
too. Two readers of "is this bullet sourced" drift, and the looser one would win the
|
|
1450
|
+
argument at exactly the moment a gate is being signed. A checklist whose own claims are
|
|
1451
|
+
unsourced is what `claim-sources` exists to refuse, and an evidence note is a claim about
|
|
1452
|
+
a claim.
|
|
1453
|
+
- **`unverified` REFUSES here, unlike in a handoff.** A citation nothing could check does
|
|
1454
|
+
not fail a stage (spec §2.8) but it is precisely what stops an AUTO gate closing (spec
|
|
1455
|
+
§5, condition 5). An agent gate is strictly stronger than an auto gate, never a cheaper
|
|
1456
|
+
one, so a `doc:` URL nothing in the workspace names cannot be what a signature rests on.
|
|
1457
|
+
- **Seven refusals, each with its own message and its own `kind`**, so a caller routes on
|
|
1458
|
+
the reason rather than on a string: unreadable or incomplete front matter · a missing
|
|
1459
|
+
section or one holding only prose · a bullet with no `src` token or one that does not
|
|
1460
|
+
resolve · `sampled > of` or `resolved + refuted > sampled` · `sampled: 0` with citations
|
|
1461
|
+
on record · a verdict that is not `sign` · a `gate:` naming a stage other than the one at
|
|
1462
|
+
the cursor. `verdict` is the kind that means "a person decides" rather than "this note is
|
|
1463
|
+
broken" — `sign-with-fixlist` and `refuse` fall to a human by design, and the verdict
|
|
1464
|
+
space is three because a reviewer can meet every acceptance criterion and still have
|
|
1465
|
+
found three real defects nobody wrote a criterion for.
|
|
1466
|
+
- **`tldrx gate template` fills what a tool can COUNT and leaves every judgement blank**:
|
|
1467
|
+
the gate at the cursor, the time, how many citations the §2.8 resolver found across the
|
|
1468
|
+
stage's declared outputs (patterns like `03-plan/stories/<id>.md` included), and how many
|
|
1469
|
+
touched paths the plan declares. The blank form deliberately does not validate — a
|
|
1470
|
+
template that parsed clean out of the box would be a signature nobody had to earn — and
|
|
1471
|
+
it writes no `[src: …]` anywhere, the same rule `questions lint --fix` follows. It spends
|
|
1472
|
+
nothing, spawns nothing, approves nothing and moves no cursor; an evidence note already on
|
|
1473
|
+
disk is left alone (exit `2`) unless `--force` says otherwise.
|
|
1474
|
+
- `validateEvidence(text, srcContext, {gate})` is the function `approve --as-agent` will
|
|
1475
|
+
call before it records anything. Nothing in this change signs a gate, reads a
|
|
1476
|
+
`gates_policy`, or writes into the run tree: the artefact layer lands first, on purpose.
|
|
1477
|
+
|
|
1478
|
+
- **Auto-gate condition 7, `boundary` — the stage stayed inside the surface the run declared.**
|
|
1479
|
+
The other six ask whether the artefact is sound and whether the work finished. None of them
|
|
1480
|
+
asks the question a reviewer asks first: *is this the work we scoped?* Measured 2026-08-30
|
|
1481
|
+
on `260830-tenancy-identity-customers` — the host ran this check BY HAND at every gate,
|
|
1482
|
+
because the framework ran it nowhere ("touches outside What boundary is NOT checked
|
|
1483
|
+
anywhere"), and that run's own S3 review surfaced the shape it was worried about: a
|
|
1484
|
+
Platform-layer file edited by a module story.
|
|
1485
|
+
- **The surface** is the union of every `file:`-kind `[src: …]` citation in
|
|
1486
|
+
`01-what/handoff.md` and `02-how/handoff.md`, and every `touches:` entry of every story
|
|
1487
|
+
under `03-plan/stories/` — or of `04-build/implicit-plan.yml` when the scope skipped Plan.
|
|
1488
|
+
A directory entry covers everything beneath it, which is how a story declares the files it
|
|
1489
|
+
is about to create and the forced companions (a lockfile, a generated client) that come
|
|
1490
|
+
with them. The citation half reuses `citedRepoPaths`, the derivation the implicit plan
|
|
1491
|
+
already builds `touches:` from — the same §2.8 tokenizer, not a second one.
|
|
1492
|
+
- **The measurement** is `git diff --name-only <default_branch>...<epic_branch>`, once per
|
|
1493
|
+
repo the plan's epics name, through the Build phase's existing git seam. Nothing is
|
|
1494
|
+
checked out, fetched or written, and the epic's own `branch:` is what is diffed — the ref
|
|
1495
|
+
`openStory` actually cut, not one re-derived here.
|
|
1496
|
+
- **Offending paths are NAMED**, up to eight then `+N more`, prefixed with their repo:
|
|
1497
|
+
`boundary=2 changed path(s), 1 outside the surface: app:platform/Auth.cs; work outside the
|
|
1498
|
+
declared surface is a boundary change — a human decides whether to widen the scope`.
|
|
1499
|
+
"1 path outside the surface" is not something anybody can act on.
|
|
1500
|
+
- **A human may still approve over it**, and that is the whole point: widening a boundary is
|
|
1501
|
+
a decision, and the framework has no basis for making it. Work nobody scoped is often the
|
|
1502
|
+
right work.
|
|
1503
|
+
- **It never refuses on an absence.** Outside Build, with no epic branch cut yet, with no
|
|
1504
|
+
repo on disk, with no plan, or on a run whose What cited no repo path at all, it is `n/a`
|
|
1505
|
+
**with the reason in the note** — a condition that could not measure must not report that
|
|
1506
|
+
it measured zero. Same shape as condition 6's `n/a (not a build stage)`.
|
|
1507
|
+
- **`tldrx-work/`, `.tldrx/` and `.agent/` are excluded from BOTH sides**, through the same
|
|
1508
|
+
`isStatePath` filter the implicit-plan derivation already applies. A handoff cites the
|
|
1509
|
+
run's own state as evidence, and in a `root_is_repo: true` workspace the state sits inside
|
|
1510
|
+
the product repo — neither is a boundary question.
|
|
1511
|
+
- **A bare citation widens the surface rather than shrinking it.** `file := [repo ":"] path
|
|
1512
|
+
":" line` makes the repo prefix optional, and `citedRepoPaths` skips the bare form because
|
|
1513
|
+
it feeds a developer prompt, where a wrong guess puts another repo's file in front of an
|
|
1514
|
+
agent. Here the risk is inverted — an unattributable citation would manufacture a false
|
|
1515
|
+
refusal — so a bare path is admitted to every repo's surface. A check that refuses wrongly
|
|
1516
|
+
is a check that gets turned off.
|
|
1517
|
+
|
|
1518
|
+
### Changed
|
|
1519
|
+
|
|
1520
|
+
- **The init report is coloured and carries a roll-up.** Repo names, confidence
|
|
1521
|
+
(green/yellow/red), the counts and the `created`/`kept` verbs are inked, and a new
|
|
1522
|
+
`files N written · N created · N kept` line answers "how much of this run was
|
|
1523
|
+
regenerated, how much is new, and how much was mine and left alone" without reading the
|
|
1524
|
+
per-file list. `stripAnsi(coloured) === uncoloured`, asserted.
|
|
1525
|
+
- `detectWorkspace` and `buildMap` take optional progress callbacks. Both default to
|
|
1526
|
+
doing nothing, so `tldrx map` and every other existing caller behave exactly as before.
|
|
1527
|
+
- `tldrx init` now writes progress bytes to stderr like every other long-running command,
|
|
1528
|
+
so the two `build.test.ts` cases that spawn it to exercise the node seam pass `--quiet`.
|
|
1529
|
+
Their `stderr === ""` assertion is how a REAL warning gets noticed, and it still is.
|
|
1530
|
+
- **`tldrx cost` is organised by ECONOMY, and prints no grand total.** `STAGE · ECONOMY ·
|
|
1531
|
+
MEASURED · DECLARED`, one footer per economy, and a third line for attempts that reported
|
|
1532
|
+
neither — no row spans both columns and nothing adds a dollar to a token. A footer that
|
|
1533
|
+
printed `$1.70` under a run which had also burned 1.5M host tokens is the sentence the
|
|
1534
|
+
label exists to stop. `--json` carries `economy` on every stage row and the set of
|
|
1535
|
+
economies on the run. Attempt lines are unchanged, all four token counters included, and a
|
|
1536
|
+
declared `--tokens` figure past a million now reads `~1.2M` rather than `~1200.0k`.
|
|
1537
|
+
|
|
1538
|
+
- `tldrx doctor` prints where the framework's own files are: a `framework <path>` line
|
|
1539
|
+
naming the installed package that ships `stages/`, `workflows/` and `templates/`, and
|
|
1540
|
+
saying that a project's overrides live in `.tldrx/stages/` and `.tldrx/workflows/`. The
|
|
1541
|
+
`/tldrx` skill says the same in two lines. Measured 2026-08-30: a real session spent
|
|
1542
|
+
1m22s on `find / -name build -type d -path "*stages*"` because nothing printed it.
|
|
1543
|
+
|
|
1544
|
+
### Fixed
|
|
1545
|
+
|
|
1546
|
+
- **A trainer that `cd`s no longer writes its knowledge file into a different git repo.**
|
|
1547
|
+
Measured 2026-08-31 on `~/scavtopia` (five repos, ten `expert train --mode light` runs): the
|
|
1548
|
+
`mcp` run was rejected with `mcp.md.partial was never written`, and the file had been written —
|
|
1549
|
+
46 lines, 9,567 bytes, complete and usable — to
|
|
1550
|
+
`whiteboard/.tldrx/experts/mcp/knowledge/mcp.md.partial`. The sub-agent ran
|
|
1551
|
+
`cd <workspace>/whiteboard` to execute that repo's declared gate command, then wrote the
|
|
1552
|
+
RELATIVE path the prompt had given it, and the path resolved against the repo it had `cd`'d
|
|
1553
|
+
into. Three costs from the one bug: **$1.23 charged for work that was finished and then
|
|
1554
|
+
orphaned**, a parasitic `.tldrx/` tree left inside an unrelated git repo (`git -C whiteboard
|
|
1555
|
+
status` → `?? .tldrx/`), and **no repair round possible** — the missing-file branch returns
|
|
1556
|
+
before the repair check, so this failure mode was unrecoverable by construction even with
|
|
1557
|
+
budget left. Fixed at both ends.
|
|
1558
|
+
- **Prevention: the prompt now states an ABSOLUTE output path**, workspace-root-resolved, and
|
|
1559
|
+
says why — "If you `cd` into a repo to run its gate command, a relative path then resolves
|
|
1560
|
+
against THAT repo … and throws the whole paid run away. That is measured, not hypothetical."
|
|
1561
|
+
Both training prompts carry it, and so does the repair round's target, for the same reason.
|
|
1562
|
+
- **Recovery: when the file is missing, the declared repo roots are probed** for the stray
|
|
1563
|
+
relative write before "never written" is said. A file found there is moved back and validated
|
|
1564
|
+
exactly as if it had landed correctly — recovery is not a pass, the same `parseKnowledgeFile`
|
|
1565
|
+
still judges it, and a recovered file that fails can still be repaired because the probe runs
|
|
1566
|
+
ABOVE the repair round.
|
|
1567
|
+
- **The note is honest and names the mess.** `recovered: the trainer wrote to
|
|
1568
|
+
whiteboard/.tldrx/… , inside the `whiteboard` repo — a relative `.tldrx/…` path resolves
|
|
1569
|
+
against whatever directory it had `cd`'d into.` The empty parasitic directories are removed
|
|
1570
|
+
on the way out; a directory holding anything else is **left in place and named**, with the
|
|
1571
|
+
`git -C <repo> status` to run, because a tool that deletes inside a repo it was never asked
|
|
1572
|
+
to touch is a worse bug than the one it is fixing. A repo carrying its own
|
|
1573
|
+
`.tldrx/workspace.yml` is skipped entirely — that file may belong to a nested workspace, and
|
|
1574
|
+
taking it would be theft rather than recovery.
|
|
1575
|
+
- When no stray is found the verdict is unchanged and now says where it looked.
|
|
1576
|
+
|
|
1577
|
+
- **A rejected training run records WHICH problems, not just how many.**
|
|
1578
|
+
Measured 2026-08-31: `components` failed with 12 problems for $1.02, and `training.jsonl` — the
|
|
1579
|
+
durable record — held only the string `"…does not validate — 12 problem(s)"`. The twelve went
|
|
1580
|
+
to stdout, where five of them were printed and the rest elided as `(+7 more)`. Anyone who had
|
|
1581
|
+
not captured stdout, which is anyone running this normally, could not tell why a $1.02 run
|
|
1582
|
+
failed. The list is now persisted twice.
|
|
1583
|
+
- **On the ledger**: `check.failed.payload` carries `problems` (the rendered per-problem
|
|
1584
|
+
lines), `problems_total`, `errors`, and `task`. The list is fitted to the record's 4 KB
|
|
1585
|
+
payload cap and reports `problems_omitted` when it does not fit — an append that THROWS on
|
|
1586
|
+
an oversize payload would take the cost line down with the reasons, which is the opposite of
|
|
1587
|
+
the point. The repair round's own `check.failed` carries what it sent back, so "what did the
|
|
1588
|
+
repair actually fix" is answerable later.
|
|
1589
|
+
- **In the file**: `<area>.rejected.md` now opens with a `# REJECTED` header — expert/area,
|
|
1590
|
+
mode, timestamp, dollars spent, error and warning counts, and every problem, uncapped —
|
|
1591
|
+
above the trainer's bytes exactly as written, separated by a rule. A quarantine with no
|
|
1592
|
+
verdict (a sub-agent that died, a rollback) gets no header: there were no reasons to state
|
|
1593
|
+
and inventing them would be inventing the reason.
|
|
1594
|
+
|
|
1595
|
+
- **`## Sources` is now taught as prose with the refused shape shown.** Same batch: four of the
|
|
1596
|
+
five problems the `components` report printed are one mistake four times — `L34 Sources: no
|
|
1597
|
+
[src: …] token`, `L35`, `L36`, `L37`. The trainer had written the recap as a bulleted list.
|
|
1598
|
+
The prompt already said "**Sources** — prose", and a writer who reads that as a style note
|
|
1599
|
+
writes bullets, because bullets are what the other four sections take. The rule it collides
|
|
1600
|
+
with is genuinely file-wide — `parseKnowledgeFile` requires a `[src: …]` token on EVERY list
|
|
1601
|
+
item in every declared section, recap included, and an unsourced one is an error that rejects
|
|
1602
|
+
the file whole. Both prompts now show the accepted prose next to the refused bullets, the same
|
|
1603
|
+
move the execution-claim rule makes. Whether an unsourced recap bullet should be a warning
|
|
1604
|
+
rather than an error is a real question and is deliberately NOT settled here.
|
|
1605
|
+
|
|
1606
|
+
- **A rejected knowledge file gets ONE repair round before the money is thrown away.**
|
|
1607
|
+
Measured 2026-08-30 on `~/scavtopia`: `tldrx expert train dotnet-stack --area dotnet --mode
|
|
1608
|
+
light` spent **$1.69**, the trainer wrote `knowledge/dotnet.md.partial`, and the validator
|
|
1609
|
+
refused it for **two** bullets that asserted an execution (`exit 0`) and cited a file line.
|
|
1610
|
+
The file went to `dotnet.rejected.md`, nothing reached `competencies.yml`, the status did not
|
|
1611
|
+
move — $1.69 for zero evidence, over a mistake the checker could name in one line and the
|
|
1612
|
+
writer could have fixed in one edit. `expert train` now hands those exact problems back to the
|
|
1613
|
+
same trainer for one more turn before anything is quarantined.
|
|
1614
|
+
- **A fresh spawn carrying the ORIGINAL prompt**, not a resumed session: `spawnAgent` has no
|
|
1615
|
+
`--resume` and the session id is captured for the ledger only. Appending to the original
|
|
1616
|
+
prompt is what keeps the repair possible at all — the citations to be fixed point into files
|
|
1617
|
+
that were inlined in that prompt and nowhere else — and the byte-identical prefix reads the
|
|
1618
|
+
cache the first turn paid to create. The appended `# REPAIR ROUND` section carries the
|
|
1619
|
+
numbered verdict, the rejected file with a line-number gutter whose numbers are the `L<n>`
|
|
1620
|
+
numbers in the verdict, and the reminder that deleting an offending bullet is a legal fix.
|
|
1621
|
+
- **One round, and the gate does not move.** The repaired file goes through the same
|
|
1622
|
+
`parseKnowledgeFile`, same shape, same scope. A second failure rejects exactly as the first
|
|
1623
|
+
used to, quarantines the same way, and returns the same exit `5`. An unsourced claim still
|
|
1624
|
+
cannot become evidence; it has simply been told once that it is unsourced.
|
|
1625
|
+
- **Paid out of `--max-usd`, never on top of it.** The repair turn's ceiling is
|
|
1626
|
+
`min(this sub-agent's share, whatever is left of the run's ceiling)`. Below the `$0.25`
|
|
1627
|
+
floor it does not spawn and says so — a cold `claude -p` that dies on
|
|
1628
|
+
`error_max_budget_usd` before its first reply costs money and produces nothing.
|
|
1629
|
+
- **The operator is told while it happens**, so an extra sub-agent never spends silently:
|
|
1630
|
+
`repairing: 3 problem(s) sent back to the trainer — one round, $0.31 of the ceiling left`,
|
|
1631
|
+
then either `repaired: the second file validates` or `the repaired file does not validate
|
|
1632
|
+
either (2 problem(s)) — one round is all there is`. Both turns land in `training.jsonl`; the
|
|
1633
|
+
repair as `task: "code:repair"`, `repair: true`, with the number of problems it was sent.
|
|
1634
|
+
- Not on `--commit`: there the sub-agent belongs to the host session and this process spawned
|
|
1635
|
+
nothing, so repairing is running `--commit` again. Not for a file that was never written —
|
|
1636
|
+
there is no verdict to send back.
|
|
1637
|
+
|
|
1638
|
+
- **The execution-claim rule is now TAUGHT, with an example and a counter-example.** The same
|
|
1639
|
+
$1.69 run is the evidence that stating it once in a paragraph does not work. Both training
|
|
1640
|
+
prompts — the spawned one and `--print-prompt` — now give the four literal shapes the checker
|
|
1641
|
+
looks for (`exit <n>`, `<n>/<n> passed`, `build is green`, and the bare word `measured` in the
|
|
1642
|
+
sentence itself), one conforming line, one refused line, and why the refused one is refused: a
|
|
1643
|
+
`workspace.yml` line DECLARES a command and is not a record of running it. Two further gaps
|
|
1644
|
+
closed: **not making the claim** is named as the other legal answer (the trainer that failed
|
|
1645
|
+
had no command in reach of the sentence it was writing, so "run it and cite it" was not
|
|
1646
|
+
actionable), and the `(measured)` **annotation** is explicitly exempted — §2.3 asks for it on
|
|
1647
|
+
every bullet, `\bmeasured\b` is one of the patterns, and nothing had ever told a writer that
|
|
1648
|
+
`claimCheck` strips the annotation before it looks. The runs-mode prompt never stated the rule
|
|
1649
|
+
at all, though the validator has always applied it to both files; it does now.
|
|
1650
|
+
|
|
1651
|
+
- **`duplicate src` is documented as non-fatal, and the rejection report stops implying
|
|
1652
|
+
otherwise.** It always was a warning — one call site, one `severity: "warning"`, one validation
|
|
1653
|
+
path for both shapes — but the report did not say which lines were fatal and the headline
|
|
1654
|
+
counted warnings as problems, so the same $1.69 run read as "3 problems" when only 2 rejected
|
|
1655
|
+
it and the third was a duplicate that costs one bullet its evidence row. `describeKnowledgeIssues`
|
|
1656
|
+
now lists errors first, prefixes warnings with `warning:` exactly as `knowledgeWarnings` does,
|
|
1657
|
+
and the headline counts errors only (new `knowledgeErrors`). The reason it is a warning is
|
|
1658
|
+
recorded where the message is emitted: "earns no second row" is a statement about scoring, not
|
|
1659
|
+
about honesty, and throwing away every other finding in a file over a repeated citation is not
|
|
1660
|
+
rigour.
|
|
1661
|
+
|
|
1662
|
+
- **A developer that FAILED is no longer recorded as a consumed attempt.** The
|
|
1663
|
+
developer-side sibling of the reviewer fix below, found by the same run on 2026-08-30.
|
|
1664
|
+
Five developer spawns on `260830-tenancy-identity-customers` died with
|
|
1665
|
+
`Reached maximum budget ($0.30 | $0.40 | $0.50 | $0.90 | $1.50)` before delivering
|
|
1666
|
+
anything the pipeline could see, and every one was settled as the story `blocked` —
|
|
1667
|
+
terminal in-run — so six of seven stories were reported as tried and failed when five of
|
|
1668
|
+
them had never been tried. A failed developer spawn now puts the story back at the status
|
|
1669
|
+
it held BEFORE the attempt (`todo`, or `review` when a reviewer had asked for changes),
|
|
1670
|
+
keeps its worktree, spends no attempt, and stops the in-process loop rather than buying
|
|
1671
|
+
the same error twice. Its `check.failed` carries `check: "developer"`,
|
|
1672
|
+
`status: "error"` and the error verbatim as `detail`; the review log, the operator line
|
|
1673
|
+
and `retro.md` all say the developer **FAILED**, never that anything was reviewed. The
|
|
1674
|
+
next `tldrx next` — headless or `--prepare` — offers the story again as a fresh developer
|
|
1675
|
+
run at the **same attempt number**. A developer that RAN and produced work its DoD faulted
|
|
1676
|
+
is a different thing entirely and still blocks, unchanged, as do two `changes` verdicts.
|
|
1677
|
+
- **Runs recorded by the old code pick those stories back up.** A `blocked` story whose last
|
|
1678
|
+
attempt recorded no commit, no check of any kind and no reviewer — the only trace the old
|
|
1679
|
+
code left, the error itself having gone to `run.yml` alone — is read as the errored spawn
|
|
1680
|
+
it was and offered again, with `S2 was blocked by a developer that FAILED (…) — that was
|
|
1681
|
+
never an attempt, so it is offered again`. It is paired with the story's own plan, because
|
|
1682
|
+
a story with an empty dod block blocks with exactly the same event shape and that block is
|
|
1683
|
+
a plan bug. Measured read-only against the live run: `tldrx next --prepare` now offers
|
|
1684
|
+
**S2** and, after it, S5, S4, S6 and S7 — while S3, which was blocked by two genuine
|
|
1685
|
+
`changes` verdicts, stays blocked.
|
|
1686
|
+
- **The auto gate will not sign a Build stage whose stories are not all `done`.** Its five
|
|
1687
|
+
conditions were all about the ARTEFACT — citations, questions, money, status — and none of
|
|
1688
|
+
them looked at what the stage was for. On the live run all five held while six of seven
|
|
1689
|
+
stories sat `blocked` and the epic branch carried one story's work, and the gate signed the
|
|
1690
|
+
stage, then signed it again after a human revoked it. A sixth condition now reads the story
|
|
1691
|
+
statuses where they live, refuses with `stories=1 of 7 done — S2:blocked, S3:blocked, …`
|
|
1692
|
+
and falls through to the human gate. A person may still approve over blocked stories —
|
|
1693
|
+
what is worth shipping is their judgement — and outside the Build phase the condition is
|
|
1694
|
+
measured as `n/a` and always holds.
|
|
1695
|
+
- **A merge that moved nothing is no longer called "merged".** `git merge --no-ff` of a
|
|
1696
|
+
branch that is already an ancestor exits 0 and says "Already up to date", and the handoff's
|
|
1697
|
+
Gate section rendered that as landed work: on the live run it read
|
|
1698
|
+
`(S1, S3, S5, S4, S7 merged)` when the epic tip carried only S1's commits. The executor now
|
|
1699
|
+
counts what the merge is about to move BEFORE it moves it — afterwards it cannot, because a
|
|
1700
|
+
merged branch is an ancestor either way — and the Gate line, the story Finding and the
|
|
1701
|
+
review log all say “added nothing — identical to `epic/x`” for a count of zero.
|
|
1702
|
+
- **A reviewer that FAILED is no longer recorded as a reviewer that asked for changes.**
|
|
1703
|
+
Found live 2026-08-30 by the first `feature`-scope run to reach Build: the headless
|
|
1704
|
+
reviewer of a 39-file, +1879-line story was given $0.26, died mid-read with
|
|
1705
|
+
`Reached maximum budget ($0.26)`, and the executor wrote that transport error down as
|
|
1706
|
+
`verdict: "changes"`. That single line spent the story's one requeue, sent a fresh
|
|
1707
|
+
developer at code nobody had faulted, and would have blocked the story after a second
|
|
1708
|
+
reviewer hit the same wall — with **zero review ever performed**. A failed reviewer now
|
|
1709
|
+
settles the story at `review` with `verdict: "error"`: the attempt counter is untouched
|
|
1710
|
+
(only a real verdict spends it), the `check.failed` event carries the error as its
|
|
1711
|
+
`detail` plus `verdict: "error"` so no ledger counts it as changes-requested, and every
|
|
1712
|
+
operator-facing line, the review log and `retro.md` all say the reviewer **FAILED**.
|
|
1713
|
+
Fail-closed is unchanged — an unfinished review is still never an approval. Inventing the
|
|
1714
|
+
verdict is what stopped.
|
|
1715
|
+
- **`tldrx next` on a story whose review errored re-runs only the REVIEW.** The diff is
|
|
1716
|
+
already committed and merged and its DoD went green, so there is nothing for a developer
|
|
1717
|
+
to redo. Both doors do it: the headless path and `tldrx next --prepare`, which used to
|
|
1718
|
+
hand the host session a full "attempt 2" developer bundle. The commit and the DoD results
|
|
1719
|
+
come back out of `events.jsonl`, so the resumed reviewer sees the same proof the first one
|
|
1720
|
+
did — including on the live run, where a `task.started` for the attempt that was never owed
|
|
1721
|
+
sits AFTER the DoD it did not run, and must not erase it. Runs recorded by the OLD code resume too — a `verdict: "changes"` whose `detail` is
|
|
1722
|
+
one of the framework's own transport errors is read as the failure it was, including a
|
|
1723
|
+
story already left at `in_progress` by a wrongly-prepared attempt 2.
|
|
1724
|
+
- **The Build executor reads `03-plan/budget.yml`.** The Plan writes a per-story price map,
|
|
1725
|
+
the Plan gate validates it, and until now **nothing read it**: the executor split its
|
|
1726
|
+
stage into equal shares, so the story priced at $4.75 and the one priced at $0.75 both got
|
|
1727
|
+
$1.03. A priced story now gets `price / (attempts x (developer + reviewer))` as its
|
|
1728
|
+
developer ceiling and a quarter of that as its reviewer's; an unpriced one keeps the
|
|
1729
|
+
uniform share; prices adding up to more than the stage are scaled down proportionally. A
|
|
1730
|
+
`budget.yml` that will not parse or validate is an advisory on stderr, never a refused
|
|
1731
|
+
build.
|
|
1732
|
+
- **A reviewer is never given less than $1.00.** Whatever the arithmetic says, clamped by
|
|
1733
|
+
what the stage has left and by `per_agent_max_usd`. A reviewer that cannot finish reading
|
|
1734
|
+
the diff approves nothing and blocks nothing — it converts the entire developer turn
|
|
1735
|
+
beside it into a story stuck at `review`, which is what $0.26 did on 2026-08-30.
|
|
1736
|
+
- **`tldrx cost` no longer prints `0 in · 0 out · 0 cache write · 0 cache read` for a turn
|
|
1737
|
+
the host declared tokens for.** `tldrx next --commit --tokens 342527` writes that number
|
|
1738
|
+
onto the task row and the `agent.result` payload, and the cost view ignored it. It now
|
|
1739
|
+
renders as `~342.5k declared (host session)`, kept apart from the four measured counters
|
|
1740
|
+
rather than folded into them: nobody measured those, and four zeroes claim the turn used
|
|
1741
|
+
no tokens.
|
|
1742
|
+
- **A stage whose declared outputs are a SHAPE no longer fails while the files sit next to
|
|
1743
|
+
the error.** Found live 2026-08-30 by the first `feature`-scope run to reach Plan: the
|
|
1744
|
+
stage wrote `03-plan/epics/E1.md` and `03-plan/stories/S1.md`..`S7.md`, and
|
|
1745
|
+
`tldrx next --commit` refused it with "`03-plan/epics/<epic>.md` was declared as an output
|
|
1746
|
+
but does not exist on disk; `03-plan/stories/<id>.md` was declared as an output but does
|
|
1747
|
+
not exist on disk". Plan cannot name its outputs — it does not know how many stories there
|
|
1748
|
+
will be until it has written them — so `stage.yml` declares the shape, and every
|
|
1749
|
+
filesystem call was asking `existsSync` about a path with a literal `<id>` in it.
|
|
1750
|
+
A declared path holding an angle-bracket token is now a **pattern**: it matches any file
|
|
1751
|
+
in that directory with the pattern's fixed prefix and suffix, resolved against the run dir
|
|
1752
|
+
and then the workspace root, in the same "first base wins" order everything else uses.
|
|
1753
|
+
The fix is in `paths.ts`, at the seam, not in the validator: `present`/`missing` count a
|
|
1754
|
+
pattern by its matches (so a stage taking `stories/<id>.md` as an INPUT gets past the gap
|
|
1755
|
+
check), the prompt is handed the concrete files rather than the shape, the previous-attempt
|
|
1756
|
+
inline shows every one of them, and `--dry-run` reverts each file it matched and names it.
|
|
1757
|
+
`{repo}` expansion is untouched and still runs first, so `{repo}` and a token compose.
|
|
1758
|
+
Plain paths behave exactly as before, down to the wording of their failure.
|
|
1759
|
+
- A pattern output that matches nothing now fails honestly — "`03-plan/stories/<id>.md` was
|
|
1760
|
+
declared as an output but **no file matches it on disk**", rather than claiming a file
|
|
1761
|
+
nobody ever named was looked for and not found. Its `sections:` contract binds **every**
|
|
1762
|
+
matched file, and the failure names the concrete file that broke it, not the shape.
|
|
1763
|
+
- **`tldrx run estimate` prices cache traffic, which is where the money actually goes.**
|
|
1764
|
+
Measured 2026-08-30 on a real workspace: a What stage was estimated at **$0.33** and the
|
|
1765
|
+
one comparable real attempt cost **$1.70** — **5x**. That attempt's ledger says why: 56
|
|
1766
|
+
input · 29.0k output · **166.3k cache write** · **3,747.1k cache read**. The estimate
|
|
1767
|
+
multiplied input and output only, so it was adding up the two columns the money was not in.
|
|
1768
|
+
Both cache counters had been on every `agent.result` since wave N and `modelPrices.ts` had
|
|
1769
|
+
carried the multipliers the whole time — nothing needed new data, only arithmetic that used
|
|
1770
|
+
it. The estimate now prices four terms: measured prompt tokens at the input rate, plus the
|
|
1771
|
+
**median** cache write (**1.25x** input), cache read (**0.1x** input) and output of past
|
|
1772
|
+
attempts at the same stage id, falling back to attempts at any stage and **naming which
|
|
1773
|
+
sample it used**. It prints the breakdown —
|
|
1774
|
+
`input ~189 · cache write ~166k · cache read ~3,747k · output ~29k → ~$1.46` — and keeps
|
|
1775
|
+
saying "ESTIMATE" in words. With no history the old behaviour stands (it refuses to guess
|
|
1776
|
+
the output half) and it now says `cache traffic not modelled — first attempt of this kind`
|
|
1777
|
+
rather than pricing a silent zero. The input and cache-write terms overlap on a cold first
|
|
1778
|
+
turn, so a first attempt leans high; that is stated in the output's own honesty line, not
|
|
1779
|
+
corrected away.
|
|
1780
|
+
- **`tldrx cost` shows the cache write / cache read columns on every attempt line**, not only
|
|
1781
|
+
on stage and run totals, and no longer hides them on a stage that ran once — previously an
|
|
1782
|
+
attempt line carried cost, task and model and nothing about where the money went.
|
|
1783
|
+
|
|
1784
|
+
- **tldrx state survives the project's own `.gitignore` rules, and `doctor` detects a rule
|
|
1785
|
+
that shadows it.** Found by a real user 2026-08-30: their repo carried the stock .NET
|
|
1786
|
+
`[Ll]og/` ignore, which swallowed `tldrx-work/<run>/04-build/log/S1.md` — the Build phase's
|
|
1787
|
+
per-story review log, which spec §1 marks committed and the handoff cites as
|
|
1788
|
+
`[src: 04-build/log/<id>.md:1]`. Nothing errored; the file was written, `git status` stayed
|
|
1789
|
+
quiet, and a teammate's clone never got it. `init`'s managed block only ever ADDED ignores,
|
|
1790
|
+
so any pre-existing project rule (`log/`, `docs/`, a `*.yml` in a subdir) could hide state
|
|
1791
|
+
and nothing noticed. The block now opens with `!tldrx-work/`, `!tldrx-work/**`, `!.tldrx/`
|
|
1792
|
+
and `!.tldrx/**` — the bare pair and the `**` pair are both needed, because gitignore cannot
|
|
1793
|
+
re-include a file whose parent directory is excluded — and the framework's own ignores follow
|
|
1794
|
+
AFTER them, since a later pattern wins. Measured with `git check-ignore -v` against a repo
|
|
1795
|
+
carrying `[Ll]og/`, not asserted from memory: the story log comes back not-ignored while the
|
|
1796
|
+
product's `Logs/build.log`, `tldrx-work/*/.lock` and `.tldrx/cache/` stay ignored. Re-running
|
|
1797
|
+
`init` upgrades a block written before this in place, markers and neighbouring rules kept.
|
|
1798
|
+
- **`tldrx doctor` now says when a rule outside that block is still hiding state.** It runs
|
|
1799
|
+
`git check-ignore --verbose --no-index -z` over four paths that must be tracked — the newest
|
|
1800
|
+
run's `run.yml` and `events.jsonl`, a synthetic `04-build/log/` probe, and
|
|
1801
|
+
`.tldrx/memory/facts.yml` — and prints each offender with the rule's own `file:line:pattern`,
|
|
1802
|
+
so a `.git/info/exclude` or a nested `.gitignore` is named too. A warning: it never moves the
|
|
1803
|
+
exit code, which is about the tools this machine has. `--json` gains `gitignoreShadow`, where
|
|
1804
|
+
`null` means no workspace was scanned rather than nothing found.
|
|
1805
|
+
- **The `## Inputs` preamble no longer claims files the budget dropped.** Measured on a
|
|
1806
|
+
real Build prompt, 2026-08-30: 9 of 15 declared inputs were inlined, the other 6 carried
|
|
1807
|
+
"It exists on disk; do not guess at its content" — and the preamble above them still read
|
|
1808
|
+
"Their full content is inlined below, so there is nothing to open and nothing else to
|
|
1809
|
+
find." The two documents the run existed to edit were among the six. The preamble is now
|
|
1810
|
+
conditional in every prompt that has one (stage prompts and the developer prompt share one
|
|
1811
|
+
renderer): with everything inlined it is the sentence it always was; with anything dropped
|
|
1812
|
+
it is `Inlined below: <n> of <m> declared inputs.` followed by "The rest exist on disk —
|
|
1813
|
+
READ them at the listed paths before relying on them; do not guess: <list>".
|
|
1814
|
+
- **A touched path the story's worktree cannot read is flagged as such.** The developer works
|
|
1815
|
+
in a worktree of the story branch, so a path that exists in the repo but is not committed
|
|
1816
|
+
at that branch is unreadable there — and `existsSync(worktree/path)` called it a file the
|
|
1817
|
+
story creates. Build now asks git (`git cat-file -e <branch>:<path>`) and marks it `NOT in
|
|
1818
|
+
this worktree — its content is only what the handoff quotes`, plus one stderr line per
|
|
1819
|
+
path: `warning: input <path> is not committed, so the story worktree cannot read it`. A
|
|
1820
|
+
path that exists nowhere is still "does not exist yet — this story creates it".
|
|
1821
|
+
- **The story's own goal wins the developer prompt's inline budget.** `touches` was spent in
|
|
1822
|
+
list order, so on that same run `AGENTS.md` — cited once in passing — was inlined whole
|
|
1823
|
+
and the two documents the goal named were in the dropped tail. Touched paths the story's
|
|
1824
|
+
`goal`, acceptance criteria, test plan or title NAME now sort first into the 64 KB; a brief
|
|
1825
|
+
that names nothing changes no order at all.
|
|
1826
|
+
- **The developer is told to run an acceptance criterion's embedded pattern BEFORE it
|
|
1827
|
+
edits.** Found on a real run's second Build of 2026-08-30: a derived criterion carried a
|
|
1828
|
+
literal grep (`` Pending `DECISIONS-NEEDED.md` # ``, backticks included) and the markers
|
|
1829
|
+
it was meant to count had been written three different ways, so it reported 0 against two
|
|
1830
|
+
files that still held five real markers — the in-session driver only caught it by
|
|
1831
|
+
measuring the inventory by hand. The developer prompt's `## Investigate` list now carries
|
|
1832
|
+
the rule verbatim: validate the pattern against the current tree first; a criterion that
|
|
1833
|
+
reports zero while the goal says the work exists is broken, so measure the real inventory,
|
|
1834
|
+
use THAT as the completion test, and record the discrepancy in the handoff. The criterion
|
|
1835
|
+
text itself stays data the story may not edit. `stages/build/stage.md` says so too.
|
|
1836
|
+
- **The implicit story no longer `touches` tldrx's own state.** `touches` is derived from
|
|
1837
|
+
every repo path the What handoff cites, and a handoff cites state as evidence: measured
|
|
1838
|
+
2026-08-30, 13 touched paths of which three were `run.yml`, a `.tldrx/triage/**/split.yml`
|
|
1839
|
+
and a `.agent/**/prompt.md`. The developer prompt inlines every touched path and calls a
|
|
1840
|
+
change outside `touches` a plan deviation, so those three read as permission to rewrite
|
|
1841
|
+
the run's own bookkeeping. Anything with `tldrx-work`, `.tldrx` or `.agent` as a path
|
|
1842
|
+
segment is now dropped from `touches` and recorded in `notes:` as `excluded <path> from
|
|
1843
|
+
touches: tldrx state is never story-writable`. Product documents are untouched.
|
|
1844
|
+
- **A document your answer settles now joins the implicit story's `touches`.** Measured on a
|
|
1845
|
+
real run, 2026-08-30: the run existed to settle six ADRs, the owner answered all six, and
|
|
1846
|
+
the one thing the story could not edit was `ADR-D013-DELIVERY-ZONE-GEOMETRY.md` — the What
|
|
1847
|
+
handoff never cited the file, `touches` is built from what the handoff cites, and the
|
|
1848
|
+
developer prompt says a change outside `touches` is a plan deviation. The plan's own
|
|
1849
|
+
`notes:` said so: "F010 settle no touched document". Build now reads the mapping rule
|
|
1850
|
+
backwards as well — a file whose name carries a decision key (`ADR-D013-*.md`,
|
|
1851
|
+
`decision-7.md`) that a fact of this run names is added, searched for beside the
|
|
1852
|
+
already-touched files first and then across the repo, under the same ≤24 cap — and writes
|
|
1853
|
+
`added <path> to touches: settled by F<n>` into `notes:`. A document no fact names is
|
|
1854
|
+
never added.
|
|
1855
|
+
- **The developer gets the WHOLE answer.** `.tldrx/memory/facts.yml` capped a fact at 300
|
|
1856
|
+
chars and `captureAnswers` writes one as `"<question> — <answer>"`, so on that same run all
|
|
1857
|
+
six answers were cut and four lost the clause naming the ADR they settle — including the
|
|
1858
|
+
words "Accepts ADR-D009 as written." The cap is now 2000 (spec §2.5; the bound only moved
|
|
1859
|
+
outwards, so every facts.yml already on disk stays valid), a cut fact ends in ` …` and
|
|
1860
|
+
carries `truncated: true`, `01-what/questions.md` is a declared input of the implicit story
|
|
1861
|
+
and is inlined into the prompt, and each apply-bullet quotes the full `[Answer]:` text and
|
|
1862
|
+
cites both the fact and the line it came from: `[src: F010; 01-what/questions.md:82]`.
|
|
1863
|
+
- **The implicit story's goal is the work, not the What's stale scoping.** With answered facts
|
|
1864
|
+
the `goal:` list holds nothing but the apply-bullets, and the What handoff's Decisions move
|
|
1865
|
+
to a `context:` list rendered under `## Context (from the What stage)` — after the objective,
|
|
1866
|
+
labelled background, explicitly not a task. Before this, a run opened to get six decisions
|
|
1867
|
+
answered told its developer, as its stated goal, "Out of scope: selecting an answer on the
|
|
1868
|
+
owner's behalf … every relevant ADR is status `proposed`". The plan note now names the facts
|
|
1869
|
+
the story is for (`… applies the run's answered decisions (F005–F010) …`). With no answers
|
|
1870
|
+
nothing moves: the What's decisions are still the goal.
|
|
1871
|
+
- **`tldrx next --prepare --discard-pending` re-derives an implicit plan**, instead of
|
|
1872
|
+
re-rendering the same story. The flag was handled only for stages with no executor, so on
|
|
1873
|
+
Build it did nothing at all: `04-build/implicit-plan.yml` is written once and read forever
|
|
1874
|
+
after, and re-preparing could not pick up a fix. It now bins the bundle's `pending.json`,
|
|
1875
|
+
`result.json` and `result.raw.json`, derives the plan again from the handoff and the answers
|
|
1876
|
+
as they stand, and prepares a fresh bundle — reusing this run's own epic branch and story
|
|
1877
|
+
worktree rather than re-cutting or refusing them. It refuses to rewind a plan something has
|
|
1878
|
+
been built off (recorded evidence, a settled story, or a commit on `story/<run>/S1` beyond
|
|
1879
|
+
the epic) and prints which of those stopped it.
|
|
1880
|
+
- **The dirty-tree check ignores tldrx's own state (`tldrx-work/`, `.tldrx/`).** In a
|
|
1881
|
+
`root_is_repo: true` workspace the framework's state lives INSIDE the product repo, so
|
|
1882
|
+
Build refused the files it had just written itself. Measured 2026-08-30: `tldrx next
|
|
1883
|
+
--prepare 260830-decisions-gate` exited 2 with `repo \`aparece-v2\` has 4 uncommitted
|
|
1884
|
+
change(s) on \`main\``, and all four were tldrx's — `run.yml` and `events.jsonl` (rewritten
|
|
1885
|
+
on every `next`), `.lock` (the run lock) and `04-build/` (the implicit plan written seconds
|
|
1886
|
+
earlier). A user's uncommitted answers under `tldrx-work/` blocked it the same way, though
|
|
1887
|
+
those are committed on the user's cadence, not as a precondition of Build. Product dirt
|
|
1888
|
+
still refuses exactly as before — same message, same fix — and the message now lists only
|
|
1889
|
+
product paths; when the only dirt was state, one line says how many files were excused. A
|
|
1890
|
+
story commit excludes the same two paths by pathspec: a story worktree is a checkout of the
|
|
1891
|
+
same repo, so `git add -A` could otherwise sweep the run folder into the diff a reviewer
|
|
1892
|
+
reads (measured: it did). Multi-repo workspaces, whose state is a sibling of the repos
|
|
1893
|
+
rather than inside them, are untouched.
|
|
1894
|
+
- **A scope that skips the Plan phase can Build.** `docs`, `hotfix`, `performance`,
|
|
1895
|
+
`prototype` and `security-patch` all list `build` in `stages:` and `plan` in `skips:`, and
|
|
1896
|
+
every one of them was a dead end: `stages/build/stage.yml` declares `03-plan/waves.yml` as
|
|
1897
|
+
an input and the executor's first act was to load `03-plan/`, so a real `docs` run parked
|
|
1898
|
+
at `04-build (ready)` could only fail its own Build stage with `03-plan/ does not
|
|
1899
|
+
validate — stories/: the Plan wrote no stories`. Build now writes the one story that
|
|
1900
|
+
decision implies into `04-build/implicit-plan.yml`, deterministically and with no model
|
|
1901
|
+
involved: title from `run.yml`, `goal` from `01-what/handoff.md` § Decisions verbatim
|
|
1902
|
+
(`[src: …]` tokens kept), `acceptance` from `01-what/success-metrics.md`, `touches` from
|
|
1903
|
+
the repo paths that handoff CITES and that exist (≤24, first-cited order, a citation with
|
|
1904
|
+
no repo prefix skipped rather than guessed at), `dod` from the commands `workspace.yml`
|
|
1905
|
+
declares for the roles the scope calls for, and `budget_usd` from the Build stage ceiling.
|
|
1906
|
+
A real `03-plan/` always wins. `tldrx next` prints one line naming the reason, and
|
|
1907
|
+
`tldrx run status` prints `plan: implicit (scope skips Plan)` so a synthesised plan never
|
|
1908
|
+
reads like one a person approved. The plan carries the work **forward**: bullets whose
|
|
1909
|
+
subject is the What stage's own deliverable are dropped on five literal signals
|
|
1910
|
+
(`questions.md`, `### Q`, an `01-what/` path, a question id, the run's-questions
|
|
1911
|
+
vocabulary) with every drop and its signal recorded in the story's `notes:`, every
|
|
1912
|
+
live fact stamped with this run adds
|
|
1913
|
+
`Apply <fact> to the touched files [src: F<n>]` to `goal`, and `acceptance` gains a check
|
|
1914
|
+
that each document one of those facts settles — the fact's text mentions that file's ADR
|
|
1915
|
+
id or decision number — no longer reads `Status: proposed`. A mapping that cannot be
|
|
1916
|
+
derived is reported in the story's `notes:` and falls back to "apply every listed fact;
|
|
1917
|
+
leave a one-line note per file saying which fact changed it", never to a guess. A fact
|
|
1918
|
+
cut at §2.5's 300-char cap is matched against the full `[Answer]:` behind it in
|
|
1919
|
+
`01-what/questions.md`: measured on a real run, `captureAnswers` had sliced the ADR
|
|
1920
|
+
clause off four of six facts, so 2 of 6 mapped on the stored text and 6 of 6 map with
|
|
1921
|
+
the answer. The grep in that criterion is complete or names `notes:` wholesale — a
|
|
1922
|
+
`(+1 more)` inside a command is something a person pastes and reads wrong. The
|
|
1923
|
+
developer prompt states plainly that Plan was skipped and this story applies the run's
|
|
1924
|
+
answered decisions.
|
|
1925
|
+
- **`skips:` in a workflow is read rather than decorative.** The schema declared the key and
|
|
1926
|
+
the loader dropped it, so nothing could tell "the Plan phase has not run yet" from "no Plan
|
|
1927
|
+
phase was ever going to run" — a distinction that cannot be made from disk, since both look
|
|
1928
|
+
like an absent `03-plan/`. `WorkflowPreset.skips` now carries it down to `StageSpec`.
|
|
1929
|
+
- **A DoD command is looked up by its `workspace.yml` KEY, not by matching the command text.**
|
|
1930
|
+
Measured on a real .NET workspace: `lint: dotnet format --verify-no-changes` has no "lint"
|
|
1931
|
+
anywhere in the string, so a text match silently found nothing and would have handed a docs
|
|
1932
|
+
run an empty Definition of Done. `WorkspaceContext.commandRoles` keeps the keys.
|
|
1933
|
+
- Build's declared `03-plan/…` inputs are treated as satisfied when the scope skips Plan, and
|
|
1934
|
+
**only** those: every other missing input is still exit 1.
|
|
1935
|
+
|
|
1936
|
+
### Verified, not changed
|
|
1937
|
+
|
|
1938
|
+
- **`tldrx expert train` already exits nonzero when a training fails.** The 2026-08-31 batch
|
|
1939
|
+
report measured shell `EXIT=0` on all ten invocations, including the three that failed their
|
|
1940
|
+
check — but nine of those ten ran on a build that predates this one (`dist/tldrx.js` was
|
|
1941
|
+
rewritten mid-batch at 05:40Z). On the current source the code path is intact:
|
|
1942
|
+
`runTraining` returns `EXIT_AGENT_FAILED` (5), `expert train` returns `outcome.code`,
|
|
1943
|
+
`dispatch` returns it, and `bin/tldrx.ts` does `process.exit(await dispatch(...))`. Now pinned
|
|
1944
|
+
by three tests that drive the REAL CLI as a subprocess with a fake `claude` on PATH and assert
|
|
1945
|
+
the PROCESS exit code — one for a file that does not validate, one for a file that was never
|
|
1946
|
+
written, one for the passing case — because "`runTraining` returns 5" and "the process exits
|
|
1947
|
+
5" are two different claims. Falsified before being trusted: making `expert train` return
|
|
1948
|
+
`EXIT_OK` breaks two of the three.
|
|
1949
|
+
|
|
1950
|
+
|
|
1951
|
+
- **The walk already skips vendored and generated trees**, and always did: `SKIPPED_DIRS`
|
|
1952
|
+
in `detect/walk.ts` covers `node_modules`, `dist`, `build`, `out`, `bin`, `obj`,
|
|
1953
|
+
`target`, `.venv`, `Pods`, `.next`, `.expo`, `coverage` and more, plus every
|
|
1954
|
+
dot-directory below the root, and it is honoured by `walkFiles` (so by `countCodeFiles`
|
|
1955
|
+
and `readSourceTree`) and by `findRepos`. There are now tests that say so: a fixture with
|
|
1956
|
+
a `.ts` file planted in each of those trees, and a real git repo inside `node_modules`
|
|
1957
|
+
that must not be reported as a workspace member. The slow part of `init` was never the
|
|
1958
|
+
walk — it is `graphify update`, once per repo.
|
|
1959
|
+
|
|
3
1960
|
## 0.3.0 — 2026-08-30
|
|
4
1961
|
|
|
5
1962
|
Every measurement below was taken on a real workspace on 2026-08-29 unless another date is
|