tldr-experts 0.14.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,10 +1,180 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## 0.14.1 — 2026-09-09
5
+
6
+ ### Fixed
7
+
8
+ - **A training test no longer runs on the fixed 5000 ms default: the spawner heuristic now
9
+ names the training shape (#194).** `test/machine-load.test.ts` decides which files must take
10
+ the load-aware budget by READING each test file for one of a few literal markers, and a
11
+ training turn matched none of them — it spawns the agent through `spawnAgent.ts`, two imports
12
+ away from any `Bun.spawn` the test file itself writes. Measured on `main` at cd8721a with a
13
+ probe that wraps `Bun.spawn` and `node:child_process` (validated first against a file the
14
+ heuristic already claims, which recorded 87 spawns, so the instrument can see the thing):
15
+ `test/knowledge-value.test.ts` spawned **3** real `claude` children while
16
+ `grep -c setDefaultTimeout` on it returned **0** — bun's fixed 5000 ms, which is the false RED
17
+ #43 was filed about, on the box's clock rather than the assertion's. The list now also names
18
+ `runTraining` and `makeTrainingWorkspace`, which claims two more files (81 → 83) and adds two
19
+ guard rows; `knowledge-value.test.ts` takes `spawnTestTimeout()`. The non-vacuity test is
20
+ anchored on that file as well as on `cli.test.ts`, because deleting a marker from a proxy list
21
+ otherwise shrinks the row set into a SHORTER green instead of a red — a marker list that can
22
+ quietly stop covering something is how this hid in the first place. The list is still a proxy:
23
+ the same probe found 14 more spawning files it does not claim, filed with the per-file counts
24
+ as #201.
25
+ - **The five phase ids are written out in one file now, not three, and a shape test refuses
26
+ the fourth (#187).** Measured on `main` at cd8721a: `PHASE_IDS`
27
+ (`src/core/run/workflowPreset.ts:28`) and `QUESTION_PHASES`
28
+ (`src/core/run/questionCards.ts:52`) were two `as const` array literals with the same five
29
+ ids in the same order, and `grep -rln "QUESTION_PHASES\|PHASE_IDS" test/` exited **1** —
30
+ nothing anywhere pinned them equal. Reading for the shape rather than the two names found a
31
+ THIRD: `questionFiles` in `src/cli/commands/questions.ts:175`, the list `questions lint`
32
+ walks, inline and unnamed — while the doc comment over `QUESTION_PHASES` claimed the list was
33
+ "kept in one place so the two verbs of `tldrx questions` cannot disagree", which was false of
34
+ the lint verb the sentence names. `workflowPreset.ts` owns the list; the other two import it,
35
+ so every existing consumer's import line is untouched and the `as const` tuple type survives
36
+ the indirection (proved with a type probe: the negative case, `99-nope` in slot five, fails
37
+ `tsc` with TS2322). `test/phase-ids-one-derivation.test.ts` pins it from both ends, because
38
+ neither end alone is enough: an identical second copy reddens the shape guard, and a copy
39
+ that has already drifted slips past that pattern and reddens the equality guard instead.
40
+ - **The changelog no longer credits a release with work it did not ship, and a gate now says so
41
+ (#200).** Measured on `main` at 0.14.0: `awk '/^## /{h=$0} /wait-gates/{print h}' CHANGELOG.md`
42
+ put #197's `--wait-gates` and `gate.timeout` bullets under `## 0.13.0 — 2026-09-08`, while
43
+ `git show v0.13.0:CHANGELOG.md | grep -c wait-gates` returned **0** — the tag that section
44
+ names never carried the flag. They were merged in 069a73e, after v0.13.1 was cut, and shipped
45
+ in v0.14.0; the implementer appended them to the FIRST `### Added` in the file instead of the
46
+ unreleased heading, which is the whole mechanism. Two older sections had the same slip:
47
+ 0.3.1 carried a528985's four bullets (73 lines, committed 07:06 on 2026-08-31, twenty-six
48
+ minutes after v0.3.1 was tagged, first shipped in v0.4.0) and 0.6.1 had lost a blank line. All three are
49
+ now byte-identical to their tags, and the bullets sit under the release that actually shipped
50
+ them — a released section is **restored**, never edited, and the two corrections that had to
51
+ land inside a tagged section (0.14.0 and 0.4.0) are recorded in `CHANGELOG.amendments` rather
52
+ than smuggled in. The v0.14.0 GitHub release notes were re-published from the corrected
53
+ section and now carry both moved bullets; v0.13.0's were generated at tag time from a changelog
54
+ that did not carry them and are already right, so they are untouched.
55
+ - **`scripts/release-check.sh` refuses a rewritten released section.** For every dated
56
+ `## X.Y.Z — YYYY-MM-DD` heading whose tag is present, the section's text must equal that
57
+ section at `git show vX.Y.Z:CHANGELOG.md` — the one copy nobody can edit afterwards — and a
58
+ mismatch fails by version, quoting the first differing line and the remedy. Nothing checked
59
+ this before, which is why three sections drifted without anyone noticing. It runs in both
60
+ `--pre-push` and `--ci`, and never fails for a reason it cannot judge: a checkout that fetched
61
+ no tags (`publish.yml`'s does not) and a tag whose own section still said `unreleased` (v0.0.2,
62
+ which predates the dating convention) are skipped and COUNTED in one line, because a silent
63
+ skip is how a gate becomes a decoration. A deliberate correction is recorded in
64
+ `CHANGELOG.amendments` (`<version> <source-sha> <why>`) — a second file on purpose, since the
65
+ failure being caught is an append nobody meant and an append edits one file. That listing is
66
+ **not a licence**, which a reviewer proved by injecting an invented bullet into an amended
67
+ section and watching the first version of this check pass it: an amended section must still
68
+ contain the tag's section as an ORDERED SUBSEQUENCE (nothing deleted, nothing reworded), and
69
+ every line it adds must exist verbatim in `<source-sha>:CHANGELOG.md`. Both halves say the same
70
+ thing — an amendment moves text the changelog already carried, and cannot write a new claim
71
+ into a shipped release. A source sha that is not a commit here is refused, not trusted.
72
+ - **An auto gate now says WHY it did not close, and the reason reaches the phone (#203).**
73
+ Measured on a real workspace, 0.14.0, the first `run auto --wait-answers 4h --wait-gates 4h`
74
+ with the notify hook: the `what` stage finished for $1.98, every declared check passed, the
75
+ policy was `auto`, and the owner got *"waiting at a auto gate that did not close by itself —
76
+ a person signs it"*. No reason. The cause was four open questions, and `evaluateAutoGate`
77
+ had computed exactly that sentence — into a stdout line nobody was watching, because
78
+ `gate.requested` was appended a hundred lines BEFORE the verdict existed. The verdict is now
79
+ taken one statement earlier, the event carries `why` and `held_by`, and the notification
80
+ renders them. Both keys are present only for an `auto` policy: on a `human` or `agent` gate
81
+ nothing measured the seven conditions, and `held_by: []` there would read as "checked, and
82
+ nothing held it".
83
+ - **The questions are notified before the gate that is downstream of them (#203).** When the
84
+ ONLY failing condition is `questions`, `question.raised` goes out first and `gate.requested`'s
85
+ notification is held back — sent only if the gate is still pending once the questions settle,
86
+ or when `--wait-gates` lapses, and never at all when the loop closes the gate itself. The
87
+ owner used to be told to sign something before being told what the thing was, which sent him
88
+ to the wrong tap. The EVENT is appended either way: this defers a notification, never an
89
+ audit record.
90
+ - **An `auto` gate closes itself when the thing holding it clears (#203).** `--wait-gates` used
91
+ to poll `gate.status` and nothing else, so an auto gate whose only blocker was an open
92
+ question permanently degraded into a `human` gate for that stage: the owner answered all four
93
+ and nothing signed. Each poll now re-runs the seven conditions off disk for an `auto` policy
94
+ and, when every one holds, signs through the **same `approve` door** `next` uses — checks
95
+ re-run off disk, actor `auto`, the seven-condition note, the ordinary `gate.approved`. Never
96
+ for `human` and never for `agent`; a person's `approve` or `reject` still lands first and
97
+ overrides at any moment, and a refusal from `approve` leaves the gate waiting rather than
98
+ reporting a close.
99
+ - **"a auto gate" is now "an auto gate".** One article, on the one sentence that reaches a lock
100
+ screen, chosen by a helper rather than a literal because it depends on the policy word and
101
+ `auto` is the only vowel among the four.
102
+
103
+ ### Added
104
+
105
+ - **A question can carry its own recommendation: `Recommended: <letter> — <why> [src: …]`
106
+ (#203).** Until now the only recommendation slot in the framework was the `agent` gate's
107
+ evidence note, which an `auto` gate never writes — so on the run above all four questions
108
+ rendered `recommendation: null`, while the stage that raised them was the one thing in the
109
+ run that knew the trade-off. The §2.7 block grammar gains one optional line, after the
110
+ options and before the `[Answer]:` slot, and the what/how/plan prompts and
111
+ `templates/questions.md` ask for one on every question with a real `[src:]`. The parse is
112
+ strict about the two machine-readable parts (one letter `A`–`E`, a dash before any prose) so
113
+ that it can be tolerant about everything else: **a line that does not match is ignored, never
114
+ refused** — it is guidance, so a typo costs the guidance and not the gate — and
115
+ `questions lint` says nothing about it. Precedence, stated once: an evidence note's
116
+ `recommend:` entry wins over the block's line for the same question id, and a question with
117
+ neither still renders no recommendation at all. Nothing manufactures one.
118
+
119
+ - **`tldrx facts add --repo <name>` is now checked against `workspace.yml`, through the one
120
+ implementation `tldrx answer --repo` already refused on (#186).** Measured on a scratch
121
+ workspace at `cd8721a`: `facts add "ghost repo test" --area test --decided-by driver --repo
122
+ ghost` exited **0** and wrote `repos: [ghost]`, while `answer Q1 "…" --repo ghost` on the same
123
+ workspace exited **1** with `--repo ghost is not a repo in this workspace — it has api`. The
124
+ mechanism was the whole story — `grep -n loadWorkspace src/cli/commands/facts.ts` exited 1:
125
+ that path never looked at what the workspace declares, it copied the flag onto the record. A
126
+ mis-scoped fact is the silent kind of wrong: nothing refuses it later, it is simply invisible
127
+ to every `{{facts}}` filter keyed on the real repo name, forever, and nothing anywhere says
128
+ why. The check is now a leaf (`src/cli/repoScope.ts`) both commands call rather than a
129
+ sentence copied into two files — two copies of a refusal drift, and the drift is silent
130
+ because each command's own test keeps passing — so `facts add` also inherits the
131
+ de-duplication the same flag already had on `answer` (`--repo api --repo api` scopes once).
132
+ Refused before the store is opened, exit **1**, `answer`'s family and `answer`'s wording,
133
+ including the "this workspace declares no repos" variant that keeps the sentence from
134
+ dangling. A test counts the sentence over `src/` and fails on the second copy.
135
+
4
136
  ## 0.14.0 — 2026-09-09
5
137
 
6
138
  ### Added
7
139
 
140
+ - **`tldrx run auto --wait-gates <duration>` — the loop waits for a signature the way it
141
+ already waits for an answer (#197).** Measured on a real workspace the day the notify hook
142
+ first drove a run: the question loop closed itself — the hook delivered Q1, the owner
143
+ answered from his chat, and `run auto` printed `waited 132s … resuming` — and then the very
144
+ next thing the stage did was reach its human gate, where the same owner approved from the
145
+ same chat and the loop had already exited 4. Three human gates a feature run is three manual
146
+ re-launches the hook was supposed to make unnecessary. The cause was scoped, not accidental:
147
+ `--wait-answers` gates its whole wait on an open-question card, which is `null` when the park
148
+ is a gate, so the wait was never reached. `--wait-gates` is a sibling flag rather than a wider
149
+ `--wait-answers`, because the two parks are closed by different verbs and calling a signature
150
+ an "answer" would be the flag name lying about what a person did. Approved → the loop carries
151
+ on; rejected → it stops and prints the note, which is now its LAST line so the `run.failed`
152
+ payload carries it to the phone of whoever has to act on it; lapsed → exit 4 with the same
153
+ lines it always had, after one `gate.timeout` (new kind, `question.timeout`'s twin, carrying
154
+ the approve and reject lines, the gate's policy, `waited_ms`, and `cost_usd` only when this
155
+ loop is the one that measured it). Nothing is spent while it polls — it reads files — and
156
+ whether a gate is pending is read through `waitingFor`, the one derivation `tldrx run status`
157
+ and the dashboard already share, never a second copy. **It waits FOR a signature and never
158
+ produces one**: there is no engine-side signing in this loop, so a stage on
159
+ `gates_policy: agent` stops it exactly as a `human` one does and is waited on identically —
160
+ an owner who switched three gates to `agent` expecting the loop to carry on was measuring
161
+ who MAY sign, not that anything had. Both wait flags may be given together, and without the
162
+ flag a gate exits 4 on the spot exactly as before.
163
+ - **The heartbeat stops telling a waiting owner that nothing is waiting on him at a GATE.**
164
+ `--notify-every`'s `status` payload learned in 0.11.1 not to say "Nothing is waiting on you"
165
+ over a run parked on a question — the fix that exists because a heartbeat is believed — but
166
+ its parked-ness came from the blocking-question predicate alone, so a run parked on a
167
+ signature got `waiting_on: []` and the exact sentence the fix was written to prevent. A
168
+ pending gate is now named in `waiting_on_gate` (`<phase>/<stage>`) beside `gate_policy`, the
169
+ summary says the run is waiting for a person to sign that stage, and `command` is the literal
170
+ `tldrx approve` line — the same spelling `gate.requested` and every decision card use, now
171
+ one exported helper instead of four literals. `waiting_on_gate` is a SIBLING key rather than
172
+ a member of `waiting_on`: an adapter maps every id in `waiting_on` to `tldrx answer <id>`,
173
+ and a stage id there would make it build a command nobody can type. Both keys are absent when
174
+ no gate is pending, so a heartbeat over a moving run is byte-identical to the one it sent
175
+ before. `gate.requested` now names the policy too, so an owner reading it on a phone knows
176
+ whether he is signing a `human` gate or overriding an `agent` one.
177
+
8
178
  - **`run auto` now closes an `agent` gate itself, over a note it wrote and had validated (#198).**
9
179
  `gates_policy: agent` said who MAY sign a gate; nothing in the engine produced the evidence note
10
180
  it is signed over. The only writer was the `tldrx gate template` skeleton a host session fills
@@ -112,44 +282,6 @@
112
282
 
113
283
  ### Added
114
284
 
115
- - **`tldrx run auto --wait-gates <duration>` — the loop waits for a signature the way it
116
- already waits for an answer (#197).** Measured on a real workspace the day the notify hook
117
- first drove a run: the question loop closed itself — the hook delivered Q1, the owner
118
- answered from his chat, and `run auto` printed `waited 132s … resuming` — and then the very
119
- next thing the stage did was reach its human gate, where the same owner approved from the
120
- same chat and the loop had already exited 4. Three human gates a feature run is three manual
121
- re-launches the hook was supposed to make unnecessary. The cause was scoped, not accidental:
122
- `--wait-answers` gates its whole wait on an open-question card, which is `null` when the park
123
- is a gate, so the wait was never reached. `--wait-gates` is a sibling flag rather than a wider
124
- `--wait-answers`, because the two parks are closed by different verbs and calling a signature
125
- an "answer" would be the flag name lying about what a person did. Approved → the loop carries
126
- on; rejected → it stops and prints the note, which is now its LAST line so the `run.failed`
127
- payload carries it to the phone of whoever has to act on it; lapsed → exit 4 with the same
128
- lines it always had, after one `gate.timeout` (new kind, `question.timeout`'s twin, carrying
129
- the approve and reject lines, the gate's policy, `waited_ms`, and `cost_usd` only when this
130
- loop is the one that measured it). Nothing is spent while it polls — it reads files — and
131
- whether a gate is pending is read through `waitingFor`, the one derivation `tldrx run status`
132
- and the dashboard already share, never a second copy. **It waits FOR a signature and never
133
- produces one**: there is no engine-side signing in this loop, so a stage on
134
- `gates_policy: agent` stops it exactly as a `human` one does and is waited on identically —
135
- an owner who switched three gates to `agent` expecting the loop to carry on was measuring
136
- who MAY sign, not that anything had. Both wait flags may be given together, and without the
137
- flag a gate exits 4 on the spot exactly as before.
138
- - **The heartbeat stops telling a waiting owner that nothing is waiting on him at a GATE.**
139
- `--notify-every`'s `status` payload learned in 0.11.1 not to say "Nothing is waiting on you"
140
- over a run parked on a question — the fix that exists because a heartbeat is believed — but
141
- its parked-ness came from the blocking-question predicate alone, so a run parked on a
142
- signature got `waiting_on: []` and the exact sentence the fix was written to prevent. A
143
- pending gate is now named in `waiting_on_gate` (`<phase>/<stage>`) beside `gate_policy`, the
144
- summary says the run is waiting for a person to sign that stage, and `command` is the literal
145
- `tldrx approve` line — the same spelling `gate.requested` and every decision card use, now
146
- one exported helper instead of four literals. `waiting_on_gate` is a SIBLING key rather than
147
- a member of `waiting_on`: an adapter maps every id in `waiting_on` to `tldrx answer <id>`,
148
- and a stage id there would make it build a command nobody can type. Both keys are absent when
149
- no gate is pending, so a heartbeat over a moving run is byte-identical to the one it sent
150
- before. `gate.requested` now names the policy too, so an owner reading it on a phone knows
151
- whether he is signing a `human` gate or overriding an `agent` one.
152
-
153
285
  - **`tldrx expert rescore [<name>] [--area <a>]` — score the knowledge you already paid for.**
154
286
  Its sibling `recompute` is arithmetic over the evidence rows already in `competencies.yml`;
155
287
  `rescore` RE-READS `knowledge/*.md` and derives their evidence again under today's rules. It
@@ -1487,6 +1619,7 @@
1487
1619
  correctly and is spliced into no prompt is the `templates/story.md` failure again (#48).
1488
1620
  6 red before, green after. `docs/spec.md` §2.13 and the `delivery` expert each carry one
1489
1621
  line pointing at the checklist.
1622
+
1490
1623
  - **The landing page now sells the unattended flow it never mentioned (#128).** Measured at
1491
1624
  `95a39db`: `grep -c 'tldrx drive' docs-site/index.md` returned `0`, and so did its Spanish
1492
1625
  twin — a bare `grep drive` exited `1` on both. `tldrx drive` is the star command for handing
@@ -4073,6 +4206,79 @@ same amount after it; what changed is that the page now says how big the bound i
4073
4206
 
4074
4207
  ### Fixed
4075
4208
 
4209
+ - **A trainer that `cd`s no longer writes its knowledge file into a different git repo.**
4210
+ Measured 2026-08-31 on `~/scavtopia` (five repos, ten `expert train --mode light` runs): the
4211
+ `mcp` run was rejected with `mcp.md.partial was never written`, and the file had been written —
4212
+ 46 lines, 9,567 bytes, complete and usable — to
4213
+ `whiteboard/.tldrx/experts/mcp/knowledge/mcp.md.partial`. The sub-agent ran
4214
+ `cd <workspace>/whiteboard` to execute that repo's declared gate command, then wrote the
4215
+ RELATIVE path the prompt had given it, and the path resolved against the repo it had `cd`'d
4216
+ into. Three costs from the one bug: **$1.23 charged for work that was finished and then
4217
+ orphaned**, a parasitic `.tldrx/` tree left inside an unrelated git repo (`git -C whiteboard
4218
+ status` → `?? .tldrx/`), and **no repair round possible** — the missing-file branch returns
4219
+ before the repair check, so this failure mode was unrecoverable by construction even with
4220
+ budget left. Fixed at both ends.
4221
+ - **Prevention: the prompt now states an ABSOLUTE output path**, workspace-root-resolved, and
4222
+ says why — "If you `cd` into a repo to run its gate command, a relative path then resolves
4223
+ against THAT repo … and throws the whole paid run away. That is measured, not hypothetical."
4224
+ Both training prompts carry it, and so does the repair round's target, for the same reason.
4225
+ - **Recovery: when the file is missing, the declared repo roots are probed** for the stray
4226
+ relative write before "never written" is said. A file found there is moved back and validated
4227
+ exactly as if it had landed correctly — recovery is not a pass, the same `parseKnowledgeFile`
4228
+ still judges it, and a recovered file that fails can still be repaired because the probe runs
4229
+ ABOVE the repair round.
4230
+ - **The note is honest and names the mess.** `recovered: the trainer wrote to
4231
+ whiteboard/.tldrx/… , inside the `whiteboard` repo — a relative `.tldrx/…` path resolves
4232
+ against whatever directory it had `cd`'d into.` The empty parasitic directories are removed
4233
+ on the way out; a directory holding anything else is **left in place and named**, with the
4234
+ `git -C <repo> status` to run, because a tool that deletes inside a repo it was never asked
4235
+ to touch is a worse bug than the one it is fixing. A repo carrying its own
4236
+ `.tldrx/workspace.yml` is skipped entirely — that file may belong to a nested workspace, and
4237
+ taking it would be theft rather than recovery.
4238
+ - When no stray is found the verdict is unchanged and now says where it looked.
4239
+
4240
+ - **A rejected training run records WHICH problems, not just how many.**
4241
+ Measured 2026-08-31: `components` failed with 12 problems for $1.02, and `training.jsonl` — the
4242
+ durable record — held only the string `"…does not validate — 12 problem(s)"`. The twelve went
4243
+ to stdout, where five of them were printed and the rest elided as `(+7 more)`. Anyone who had
4244
+ not captured stdout, which is anyone running this normally, could not tell why a $1.02 run
4245
+ failed. The list is now persisted twice.
4246
+ - **On the ledger**: `check.failed.payload` carries `problems` (the rendered per-problem
4247
+ lines), `problems_total`, `errors`, and `task`. The list is fitted to the record's 4 KB
4248
+ payload cap and reports `problems_omitted` when it does not fit — an append that THROWS on
4249
+ an oversize payload would take the cost line down with the reasons, which is the opposite of
4250
+ the point. The repair round's own `check.failed` carries what it sent back, so "what did the
4251
+ repair actually fix" is answerable later.
4252
+ - **In the file**: `<area>.rejected.md` now opens with a `# REJECTED` header — expert/area,
4253
+ mode, timestamp, dollars spent, error and warning counts, and every problem, uncapped —
4254
+ above the trainer's bytes exactly as written, separated by a rule. A quarantine with no
4255
+ verdict (a sub-agent that died, a rollback) gets no header: there were no reasons to state
4256
+ and inventing them would be inventing the reason.
4257
+
4258
+ - **`## Sources` is now taught as prose with the refused shape shown.** Same batch: four of the
4259
+ five problems the `components` report printed are one mistake four times — `L34 Sources: no
4260
+ [src: …] token`, `L35`, `L36`, `L37`. The trainer had written the recap as a bulleted list.
4261
+ The prompt already said "**Sources** — prose", and a writer who reads that as a style note
4262
+ writes bullets, because bullets are what the other four sections take. The rule it collides
4263
+ with is genuinely file-wide — `parseKnowledgeFile` requires a `[src: …]` token on EVERY list
4264
+ item in every declared section, recap included, and an unsourced one is an error that rejects
4265
+ the file whole. Both prompts now show the accepted prose next to the refused bullets, the same
4266
+ move the execution-claim rule makes. Whether an unsourced recap bullet should be a warning
4267
+ rather than an error is a real question and is deliberately NOT settled here.
4268
+
4269
+
4270
+ - **`tldrx expert train` already exits nonzero when a training fails.** The 2026-08-31 batch
4271
+ report measured shell `EXIT=0` on all ten invocations, including the three that failed their
4272
+ check — but nine of those ten ran on a build that predates this one (`dist/tldrx.js` was
4273
+ rewritten mid-batch at 05:40Z). On the current source the code path is intact:
4274
+ `runTraining` returns `EXIT_AGENT_FAILED` (5), `expert train` returns `outcome.code`,
4275
+ `dispatch` returns it, and `bin/tldrx.ts` does `process.exit(await dispatch(...))`. Now pinned
4276
+ by three tests that drive the REAL CLI as a subprocess with a fake `claude` on PATH and assert
4277
+ the PROCESS exit code — one for a file that does not validate, one for a file that was never
4278
+ written, one for the passing case — because "`runTraining` returns 5" and "the process exits
4279
+ 5" are two different claims. Falsified before being trusted: making `expert train` return
4280
+ `EXIT_OK` breaks two of the three.
4281
+
4076
4282
  - **`tldrx learn` — the cold-player QA round (#30).** A first-time player played all eight chapters
4077
4283
  and returned SHIP-with-fixlist. Everything they found is fixed or recorded:
4078
4284
  - **Chapter 8 no longer lies about the brake.** It said "the phase has already spent its Watch
@@ -5274,66 +5480,6 @@ none of these behaves byte-identically to the release before them.
5274
5480
 
5275
5481
  ### Fixed
5276
5482
 
5277
- - **A trainer that `cd`s no longer writes its knowledge file into a different git repo.**
5278
- Measured 2026-08-31 on `~/scavtopia` (five repos, ten `expert train --mode light` runs): the
5279
- `mcp` run was rejected with `mcp.md.partial was never written`, and the file had been written —
5280
- 46 lines, 9,567 bytes, complete and usable — to
5281
- `whiteboard/.tldrx/experts/mcp/knowledge/mcp.md.partial`. The sub-agent ran
5282
- `cd <workspace>/whiteboard` to execute that repo's declared gate command, then wrote the
5283
- RELATIVE path the prompt had given it, and the path resolved against the repo it had `cd`'d
5284
- into. Three costs from the one bug: **$1.23 charged for work that was finished and then
5285
- orphaned**, a parasitic `.tldrx/` tree left inside an unrelated git repo (`git -C whiteboard
5286
- status` → `?? .tldrx/`), and **no repair round possible** — the missing-file branch returns
5287
- before the repair check, so this failure mode was unrecoverable by construction even with
5288
- budget left. Fixed at both ends.
5289
- - **Prevention: the prompt now states an ABSOLUTE output path**, workspace-root-resolved, and
5290
- says why — "If you `cd` into a repo to run its gate command, a relative path then resolves
5291
- against THAT repo … and throws the whole paid run away. That is measured, not hypothetical."
5292
- Both training prompts carry it, and so does the repair round's target, for the same reason.
5293
- - **Recovery: when the file is missing, the declared repo roots are probed** for the stray
5294
- relative write before "never written" is said. A file found there is moved back and validated
5295
- exactly as if it had landed correctly — recovery is not a pass, the same `parseKnowledgeFile`
5296
- still judges it, and a recovered file that fails can still be repaired because the probe runs
5297
- ABOVE the repair round.
5298
- - **The note is honest and names the mess.** `recovered: the trainer wrote to
5299
- whiteboard/.tldrx/… , inside the `whiteboard` repo — a relative `.tldrx/…` path resolves
5300
- against whatever directory it had `cd`'d into.` The empty parasitic directories are removed
5301
- on the way out; a directory holding anything else is **left in place and named**, with the
5302
- `git -C <repo> status` to run, because a tool that deletes inside a repo it was never asked
5303
- to touch is a worse bug than the one it is fixing. A repo carrying its own
5304
- `.tldrx/workspace.yml` is skipped entirely — that file may belong to a nested workspace, and
5305
- taking it would be theft rather than recovery.
5306
- - When no stray is found the verdict is unchanged and now says where it looked.
5307
-
5308
- - **A rejected training run records WHICH problems, not just how many.**
5309
- Measured 2026-08-31: `components` failed with 12 problems for $1.02, and `training.jsonl` — the
5310
- durable record — held only the string `"…does not validate — 12 problem(s)"`. The twelve went
5311
- to stdout, where five of them were printed and the rest elided as `(+7 more)`. Anyone who had
5312
- not captured stdout, which is anyone running this normally, could not tell why a $1.02 run
5313
- failed. The list is now persisted twice.
5314
- - **On the ledger**: `check.failed.payload` carries `problems` (the rendered per-problem
5315
- lines), `problems_total`, `errors`, and `task`. The list is fitted to the record's 4 KB
5316
- payload cap and reports `problems_omitted` when it does not fit — an append that THROWS on
5317
- an oversize payload would take the cost line down with the reasons, which is the opposite of
5318
- the point. The repair round's own `check.failed` carries what it sent back, so "what did the
5319
- repair actually fix" is answerable later.
5320
- - **In the file**: `<area>.rejected.md` now opens with a `# REJECTED` header — expert/area,
5321
- mode, timestamp, dollars spent, error and warning counts, and every problem, uncapped —
5322
- above the trainer's bytes exactly as written, separated by a rule. A quarantine with no
5323
- verdict (a sub-agent that died, a rollback) gets no header: there were no reasons to state
5324
- and inventing them would be inventing the reason.
5325
-
5326
- - **`## Sources` is now taught as prose with the refused shape shown.** Same batch: four of the
5327
- five problems the `components` report printed are one mistake four times — `L34 Sources: no
5328
- [src: …] token`, `L35`, `L36`, `L37`. The trainer had written the recap as a bulleted list.
5329
- The prompt already said "**Sources** — prose", and a writer who reads that as a style note
5330
- writes bullets, because bullets are what the other four sections take. The rule it collides
5331
- with is genuinely file-wide — `parseKnowledgeFile` requires a `[src: …]` token on EVERY list
5332
- item in every declared section, recap included, and an unsourced one is an error that rejects
5333
- the file whole. Both prompts now show the accepted prose next to the refused bullets, the same
5334
- move the execution-claim rule makes. Whether an unsourced recap bullet should be a warning
5335
- rather than an error is a real question and is deliberately NOT settled here.
5336
-
5337
5483
  - **A rejected knowledge file gets ONE repair round before the money is thrown away.**
5338
5484
  Measured 2026-08-30 on `~/scavtopia`: `tldrx expert train dotnet-stack --area dotnet --mode
5339
5485
  light` spent **$1.69**, the trainer wrote `knowledge/dotnet.md.partial`, and the validator
@@ -5666,19 +5812,6 @@ none of these behaves byte-identically to the release before them.
5666
5812
 
5667
5813
  ### Verified, not changed
5668
5814
 
5669
- - **`tldrx expert train` already exits nonzero when a training fails.** The 2026-08-31 batch
5670
- report measured shell `EXIT=0` on all ten invocations, including the three that failed their
5671
- check — but nine of those ten ran on a build that predates this one (`dist/tldrx.js` was
5672
- rewritten mid-batch at 05:40Z). On the current source the code path is intact:
5673
- `runTraining` returns `EXIT_AGENT_FAILED` (5), `expert train` returns `outcome.code`,
5674
- `dispatch` returns it, and `bin/tldrx.ts` does `process.exit(await dispatch(...))`. Now pinned
5675
- by three tests that drive the REAL CLI as a subprocess with a fake `claude` on PATH and assert
5676
- the PROCESS exit code — one for a file that does not validate, one for a file that was never
5677
- written, one for the passing case — because "`runTraining` returns 5" and "the process exits
5678
- 5" are two different claims. Falsified before being trusted: making `expert train` return
5679
- `EXIT_OK` breaks two of the three.
5680
-
5681
-
5682
5815
  - **The walk already skips vendored and generated trees**, and always did: `SKIPPED_DIRS`
5683
5816
  in `detect/walk.ts` covers `node_modules`, `dist`, `build`, `out`, `bin`, `obj`,
5684
5817
  `target`, `.venv`, `Pods`, `.next`, `.expo`, `coverage` and more, plus every
package/README.md CHANGED
@@ -316,6 +316,7 @@ back on the registry is 0.3.0.
316
316
 
317
317
  | Version | Date | Status | Contains |
318
318
  |---|---|---|---|
319
+ | 0.14.1 | 2026-09-09 | `beta` | an auto gate that says what holds it and closes itself, and three derivations cut to one: measured on a real workspace at 0.14.0, the first `run auto --wait-answers 4h --wait-gates 4h` with a notify hook finished its What stage for $1.98 with every declared check green and told the owner *"waiting at a auto gate that did not close by itself — a person signs it"* — no reason, because `evaluateAutoGate` had computed exactly that sentence into a stdout line nobody was watching while `gate.requested` was appended a hundred lines BEFORE the verdict existed; the verdict is now taken one statement earlier and `why`/`held_by` ride the event to the phone (present only for an `auto` policy, since `held_by: []` on a `human` gate would read as "checked, and nothing held it"), the questions that hold a gate are notified BEFORE the gate downstream of them so the owner stops being told to sign a thing before being told what it is, and an `auto` gate whose only blocker was an open question stops permanently degrading into a `human` one — each poll re-runs the seven conditions off disk and signs through the same `approve` door `next` uses, never for `human` and never for `agent`, a person's `approve` or `reject` still landing first; a question can carry its own `Recommended: <letter> — <why> [src: …]`, parsed tolerantly so an older note reads as not recorded rather than refusing; `tldrx facts add --repo <name>` is now checked against `workspace.yml` through the ONE leaf `answer --repo` already refused on, because the unvalidated flag wrote `repos: [ghost]` at exit 0 and every prompt or filter keyed on a real repo name was then silently blind to that fact; the five phase ids are written out in one file instead of three — `PHASE_IDS`, `QUESTION_PHASES`, and an inline literal walked by `questions lint` that `QUESTION_PHASES`' own doc comment claimed could not exist — pinned from both ends, because an identical second copy and a copy that has already drifted redden different guards and neither alone is enough; a training test stops running on bun's fixed 5000 ms after a spawn probe, validated first against a file the heuristic already claimed (87 spawns recorded, so the instrument can see the thing), found it spawning three real children unclaimed by any marker, with the 14 further unclaimed files filed rather than fixed; and `release-check.sh` refuses a rewritten released section — every dated heading whose tag is present must equal `git show vX.Y.Z:CHANGELOG.md` byte for byte, three sections having drifted with nothing checking, and an amendment recorded in `CHANGELOG.amendments` must still contain the tag's section as an ordered subsequence and may only add lines that exist verbatim in its source sha, after a reviewer proved the first version of that check happily passed an invented bullet |
319
320
  | 0.14.0 | 2026-09-09 | `beta` | an `agent` gate the engine can actually close, and three computations of the same answer cut to one: `gates_policy: agent` named who MAY sign a gate but nothing in the engine produced the evidence note it is signed over — measured on 0.13.1, an owner ran `tldrx run gates set what:agent`, was told "an agent may now close it", and the loop stopped at the next gate anyway with exit 4 — so `run auto` now spawns one bounded **gate signer** when a stage's checks pass under an `agent` policy: the stage's own model and effort, a quarter of its per-agent ceiling, a tool allowance that reads anything and writes exactly one file, and a prompt carrying the stage's declared outputs, the seven `auto` conditions as measured and the §2.8 skeleton `gate template` itself renders — the note going through the UNCHANGED `approve --as-agent` path, so a refusal, a note that does not validate, a signer that wrote nothing and a signer that died are one outcome, pending for a person with the reason named on stdout and now in the `gate.requested` summary, and there is no flag to turn it on because an `agent` policy is already the owner's recorded decision; the turn is recorded like any other (`role: gate-signer`, a `run.yml` task row, a row in `tldrx cost`) and taken BEFORE the stage moves to `awaiting_gate`, because the other order had a person sign the gate the engine was mid-signing, three runs out of three; and the suite stops being run three times per change — the pre-merge reviewer now runs only the test files that cover its diff plus `typecheck`, never the full `bun test`, since the wave re-runs every gate on the MERGED tree anyway, `publish.yml` refuses unless `ci` has a `success` run for the same sha instead of recomputing typecheck/tests/build (~87 min/week of runner time, with a `cancelled` ci run failing by name and the remedy), `ci` cancels a run a newer push has already superseded (25 of 122 push runs began under 10 minutes apart), the docs deploy finally fires on `src/cli/helpText.ts` so a help-registry change stops deploying nothing while the published CLI reference goes stale, and `AGENTS.md` §2 now says out loud that a slash in a branch name is a directory, which is what the review-record gate builds |
320
321
  | 0.13.1 | 2026-09-08 | `beta` | a stage prompt that opens by saying what to do: every stage prompt now leads with a generated brief — who the reader is, which stage of which run, that the template below is to be FILLED, the exact path of every declared output, and that a question goes in the questions file rather than back to an operator who is not there — because on a real workspace at 0.13.0 a What sub-agent read its 66,452-byte prompt, found no request in it, wrote none of its six declared outputs and asked what to do, $0.29 spent; the brief is generated from the same `outputs:` list `pending.json` records, so it cannot name a path the commit will not look for, and the failure was never a regression — the spliced citation grammar grew the stage section 5,007 B → 13,180 B and the missing instruction was finally outnumbered; and the `N runs are open` nudge, the one imperative-shaped sentence in that agent's window and the one it duly answered, stops reaching sub-agents at all — `spawnAgent` marks every child it spawns and `session-start` emits nothing when it sees the marker, an absent marker still being a human's session and behaving exactly as before |
321
322
  | 0.13.0 | 2026-09-08 | `beta` | evidence a role expert can actually earn, and a review that leaves a record: `--mode full`'s runs pass mines `tldrx-work/**` while the domain gate judged every citation it produced against folders of code — measured at four role experts, **$9.47 and one evidence row**, and unfixable from the workspace because the single spelling the matcher would reach is the one `domainPaths()` drops — so the gate now treats the run record as in-domain for the file mined FROM it, scoped to the pass and never to the expert's `kind:` (a light file citing a handoff is still out of domain and still says so), two shipped role templates stop declaring `.tldrx/map/**` and `.tldrx/map/{repo}/gotchas.md` paths that matched nothing at all, and a pass that validated, spent money and earned zero rows now prints `the level did not move — $X.XX bought 0 evidence row(s)` with its reasons carried into `check.passed` instead of a silent ledger; `tldrx expert rescore` recovers what was already bought for $0 by re-reading `knowledge/*.md` under today's rules, dating rows by the knowledge file's own `trained_at` and never by the clock, with `rescored_at` additive beside `at` — its ABSENCE keeping the meaning every existing row had — and one `evidence.rescored` line per file it actually moved, so a free re-derivation can never be read as a paid turn; `scripts/merge-wave.sh` refuses a branch carrying no `.review/<branch>.md` with **exit 10**, its own code because `2` in that script is already "merge conflict", a stale record refusing rather than warning and staleness measured as "the code moved" rather than "the sha differs", since committing the record moves the head past exactly the sha it names; and the mutation check moves from the reviewer, whose allowance is `Read`/`Grep`/`Glob`/`Bash(git diff *)` and holds no pen, to the developer's contract that can run it, leaving the reviewer the read it can actually perform |
@@ -1,20 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  conflictOf
4
- } from "./chunk-3ns54mnq.js";
4
+ } from "./chunk-djsf80bc.js";
5
5
  import {
6
6
  FactsStore,
7
7
  formatJaccard
8
- } from "./chunk-nwzevx0j.js";
8
+ } from "./chunk-yn87xw4f.js";
9
9
  import {
10
10
  parseHookInput,
11
11
  readStdin
12
- } from "./chunk-mvz7jwt8.js";
12
+ } from "./chunk-cyzm0n45.js";
13
+ import {
14
+ PHASE_IDS
15
+ } from "./chunk-dzkg8c68.js";
13
16
  import {
14
17
  EventLog,
15
18
  PHASE_ID_RE
16
- } from "./chunk-f1w9sy07.js";
17
- import"./chunk-yvgkw0gm.js";
19
+ } from "./chunk-ek3tsewd.js";
20
+ import"./chunk-693npq3d.js";
18
21
  import {
19
22
  ADVISORY_KEY,
20
23
  MAX_FACT_CHARS,
@@ -24,7 +27,7 @@ import {
24
27
  renderQuestionBlock,
25
28
  replaceBlock,
26
29
  serializeQuestions
27
- } from "./chunk-1knfmj6j.js";
30
+ } from "./chunk-381n8p4g.js";
28
31
  import {
29
32
  ITERATION_ONLY_SLOT,
30
33
  PROJECT_FRAMEWORK_DIR,
@@ -32,7 +35,7 @@ import {
32
35
  commandProbeIssues,
33
36
  factsPath,
34
37
  parseYaml
35
- } from "./chunk-6fywd8xd.js";
38
+ } from "./chunk-xe36zh9a.js";
36
39
 
37
40
  // src/hooks/answer-capture.ts
38
41
  import { existsSync as existsSync5 } from "fs";
@@ -334,7 +337,7 @@ import { appendFileSync as appendFileSync2, existsSync as existsSync3, readFileS
334
337
  import { join as join3 } from "node:path";
335
338
 
336
339
  // src/core/run/questionCards.ts
337
- var QUESTION_PHASES = ["01-what", "02-how", "03-plan", "04-build", "05-watch"];
340
+ var QUESTION_PHASES = PHASE_IDS;
338
341
 
339
342
  // src/core/answers/raiseConflict.ts
340
343
  var RAISED_BY = "tldrx";
@@ -379,6 +382,7 @@ function raiseConflictQuestion(args) {
379
382
  { letter: "B", text: `${args.oldFactId} is right — supersede ${args.newFactId}` },
380
383
  { letter: "C", text: "Both are partly right — write the correction below" }
381
384
  ],
385
+ recommended: null,
382
386
  answer: "",
383
387
  answerIndex: -1,
384
388
  footer: null,
@@ -1,15 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  budgetGateDeny
4
- } from "./chunk-qefqmgjn.js";
4
+ } from "./chunk-vnnmbjkz.js";
5
5
  import {
6
6
  allow,
7
7
  deny,
8
8
  readPayload,
9
9
  runHook,
10
10
  toolInput
11
- } from "./chunk-v2xzr4qd.js";
12
- import"./chunk-mvz7jwt8.js";
11
+ } from "./chunk-5m3f8wme.js";
12
+ import"./chunk-cyzm0n45.js";
13
13
  import {
14
14
  asRunBudget,
15
15
  currentActor,
@@ -29,20 +29,20 @@ import {
29
29
  validateRunBudget,
30
30
  wouldExceed,
31
31
  wouldExceedHostTokens
32
- } from "./chunk-fsgsn8vb.js";
32
+ } from "./chunk-g73p9wgp.js";
33
33
  import {
34
34
  EventLog
35
- } from "./chunk-f1w9sy07.js";
35
+ } from "./chunk-ek3tsewd.js";
36
36
  import {
37
37
  noteDeprecations
38
- } from "./chunk-1knfmj6j.js";
38
+ } from "./chunk-381n8p4g.js";
39
39
  import {
40
40
  PROJECT_WORK_DIR,
41
41
  findWorkspaceRoot,
42
42
  locateWork,
43
43
  parseYaml,
44
44
  stageYamlPath
45
- } from "./chunk-6fywd8xd.js";
45
+ } from "./chunk-xe36zh9a.js";
46
46
 
47
47
  // src/hooks/budget-gate.ts
48
48
  import { existsSync as existsSync2, readFileSync as readFileSync2, statSync } from "node:fs";
@@ -1,6 +1,9 @@
1
1
  import {
2
- parseSrcToken
3
- } from "./chunk-6fywd8xd.js";
2
+ SRC_SEPARATOR,
3
+ parseSrcToken,
4
+ srcToken,
5
+ withoutSrcToken
6
+ } from "./chunk-xe36zh9a.js";
4
7
 
5
8
  // src/core/text/questions.ts
6
9
  var REQUIRED_METADATA_KEYS = ["id", "status", "area", "asked_by", "asked_at"];
@@ -8,6 +11,7 @@ var HEADING_RE = /^##\s+(Q\d{1,6})\s+·\s+(.+?)\s*$/;
8
11
  var METADATA_RE = /^<!--\s*(.*?)\s*-->$/;
9
12
  var WHY_RE = /^Why asked:\s*(.*)$/;
10
13
  var OPTION_RE = /^-\s+([A-E])\)\s*(.*)$/;
14
+ var RECOMMENDED_RE = /^Recommended:[ \t]+([A-E])\)?[ \t]*(?:[—–-][ \t]+(.*))?$/;
11
15
  var ANSWER_RE = /^\[Answer\]:[ \t]*(\S.*)$/;
12
16
  var ANSWER_SLOT_RE = /^\[Answer\]:/;
13
17
  var FOOTER_KEYS = ["answered_by", "answered_at", "fact"];
@@ -50,6 +54,7 @@ function buildBlock(startLine, lines) {
50
54
  let whyAsked = null;
51
55
  let whySrc = null;
52
56
  const options = [];
57
+ let recommended = null;
53
58
  let answer = "";
54
59
  let answerIndex = -1;
55
60
  let footer = null;
@@ -77,6 +82,11 @@ function buildBlock(startLine, lines) {
77
82
  options.push({ letter: option[1], text: option[2] });
78
83
  continue;
79
84
  }
85
+ const recommendation = RECOMMENDED_RE.exec(line);
86
+ if (recommendation !== null && recommendation[1] !== undefined && recommended === null) {
87
+ recommended = toRecommendation(recommendation[1], recommendation[2] ?? "");
88
+ continue;
89
+ }
80
90
  if (ANSWER_SLOT_RE.test(line) && answerIndex === -1) {
81
91
  answerIndex = i;
82
92
  const captured = ANSWER_RE.exec(line);
@@ -91,6 +101,7 @@ function buildBlock(startLine, lines) {
91
101
  whyAsked,
92
102
  whySrc,
93
103
  options,
104
+ recommended,
94
105
  answer,
95
106
  answerIndex,
96
107
  footer,
@@ -98,6 +109,14 @@ function buildBlock(startLine, lines) {
98
109
  lines: [...lines]
99
110
  };
100
111
  }
112
+ function toRecommendation(option, rest) {
113
+ const token = parseSrcToken(rest);
114
+ return {
115
+ option,
116
+ why: withoutSrcToken(rest).trim(),
117
+ src: token === null ? "" : token.refs.map((ref) => ref.raw).join(SRC_SEPARATOR)
118
+ };
119
+ }
101
120
  function parsePipeComment(inner) {
102
121
  const pairs = [];
103
122
  for (const part of inner.split("|")) {
@@ -155,6 +174,11 @@ function renderQuestionBlock(block) {
155
174
  lines.push("");
156
175
  for (const option of block.options)
157
176
  lines.push(`- ${option.letter}) ${option.text}`);
177
+ if (block.recommended !== null) {
178
+ const rec = block.recommended;
179
+ const why = rec.why.trim() === "" ? "" : ` — ${rec.why.trim()}`;
180
+ lines.push("", `Recommended: ${rec.option}${why}${rec.src === "" ? "" : ` ${srcToken([rec.src])}`}`);
181
+ }
158
182
  lines.push("");
159
183
  lines.push(block.answer === "" ? "[Answer]:" : `[Answer]: ${block.answer}`);
160
184
  if (block.footer !== null) {
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  parseHookInput,
3
3
  readStdin
4
- } from "./chunk-mvz7jwt8.js";
4
+ } from "./chunk-cyzm0n45.js";
5
5
 
6
6
  // src/hooks/lib/decide.ts
7
7
  function deny(reason) {
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  PROJECT_FRAMEWORK_DIR,
3
3
  PROJECT_WORK_DIR
4
- } from "./chunk-6fywd8xd.js";
4
+ } from "./chunk-xe36zh9a.js";
5
5
 
6
6
  // src/core/lock/workspaceLock.ts
7
7
  import { existsSync as existsSync2, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync2, rmSync as rmSync2, writeSync, closeSync } from "node:fs";