tldr-experts 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,328 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.0 — 2026-09-13
4
+
5
+ ### Added
6
+
7
+ - **A run can now end in a pull request toward main, on purpose (#253).** Measured on two
8
+ workspaces on 0.16.1: $89.82 over 28 h and $26.38 over 5.6 h, and neither run could end in a
9
+ PR whatever it spent — every preset ends at `watch`, and `tldrx ship` refused to push the
10
+ branch it opens the PR for, citing a spec sentence that is not in the spec (what
11
+ `docs/spec.md` actually says is that the BUILD phase has no push wrapper). The last mile —
12
+ the only one the owner's sentence names, *"desde que le doy el run, hasta que me entrega un
13
+ PR hacia main"* — was a person typing `git push -u origin epic/<slug>`, then `tldrx ship`,
14
+ then watching the checks. It is now a decision taken once, at `tldrx run new --ship
15
+ <push|pr|merge>`, and frozen into `run.yml` as an additive `ship: {push, pr, auto_merge}`
16
+ block the way `gates_policy` is: not a sixth phase, because shipping has none of a phase's
17
+ properties — it spends nothing, produces a URL, and is gated by what every earlier gate
18
+ already signed. When `run auto` sees the run close it runs the same `tldrx ship` a person
19
+ would type: the epic is pushed through the ONE push wrapper in the codebase (in
20
+ `core/build/git.ts`, beside Build's git seam, with `core/run/ship.ts` pinned as its single
21
+ caller — the Build phase itself still has none), the PR opens with the body it always had,
22
+ and under `merge` the PR is armed with `gh pr merge --auto --merge` so the remote's own
23
+ checks decide. A PR that reports NO check is left open and the record says `merge: absent —
24
+ no checks to wait on` (§7), because GitHub's auto-merge over nothing to wait on is a merge
25
+ now, not a merge when green — the mutation that drops that guard reddens exactly one test.
26
+ `run.yml` gets one record (`pr_urls`, `merge`, per-repo `merges`, `shipped_at`) beside the
27
+ policy, so a loop re-run on a closed run ships nothing twice — while `tldrx ship` typed again
28
+ is the recovery after a partial failure: it re-arms the repo whose merge failed, leaves a
29
+ queued one alone, and never erases a recorded failure (pre-merge review caught the first
30
+ version overwriting it with an empty string and exit 0) — and `run.finished` carries `pr_url`
31
+ and `merge`.
32
+ A ship that is refused is the loop's exit 2 — it was asked for a PR and did not deliver one.
33
+ Absent the flag every run means what it meant: nothing pushed, nothing opened, the refusal
34
+ that names the `git push` command unchanged byte for byte. Every gate is still signed by
35
+ whoever `--gates` says, so an unattended ship is a deliberate `--gates none --ship merge`
36
+ and `run.yml` records both. Found on the way: `run.yml` is emitted key by key, so the
37
+ block was dropped by the first save until the emitter learned it — pinned by a round-trip
38
+ test.
39
+ - **`questions_policy: recommended` — the loop answers a question that carries its own
40
+ `Recommended:` line, and escalates only the ones that do not (#251).** Measured 2026-09-12 on
41
+ two headless runs at 0.16.1, every gate `auto`, `--wait-answers 8h --wait-gates 8h`: $89.82
42
+ and 28 h wall on one, $26.38 and 5.6 h on the other, the agents busy 4.9 h and 56 min of it,
43
+ and **10 owner questions, 9 carrying a `Recommended:` line the asking agent had written**. The
44
+ loop parked on every one, sent `question.raised`, and waited for a person to type
45
+ `tldrx answer` — over a pick the framework already held in a parsed field (`RECOMMENDED_RE`),
46
+ on the decision card and in the notification payload. Nothing acted on it, by design:
47
+ `waitForAnswers` "reads and does nothing else", and `Fact.decided_by` had no honest value for
48
+ a machine's decision. So: `run new --questions <stage,stage|stage:recommended|all|none>` —
49
+ `--gates`' grammar, extracted to ONE parser both flags now call rather than a second copy of
50
+ "split on the first colon" — freezes `questions_policy` into `run.yml` beside `gates_policy`,
51
+ additive and absent by default, so a run opened without the flag is byte-identical to one
52
+ opened before it existed and reads `human` everywhere. Under `recommended` for the cursor
53
+ stage, the moment a stage parks the loop takes each blocking question whose block names one
54
+ of its own options on a `Recommended:` line, through the SAME `writeAnswerSlot` +
55
+ `captureAnswers` that `tldrx answer` is — the footer, the `question.answered` and the
56
+ `fact.added` are a person's bytes — and the fact says the rest: `decided_by: agent-default`
57
+ (a THIRD value beside `owner`/`driver`, because `owner` there would be an audit record lying
58
+ in the dangerous direction), `alternatives` (the options not taken) and `recommended_why`
59
+ (the line's own reason). One `question.auto_answered` per answer reaches the notify hook with
60
+ the pick, the alternatives and the `--supersede` line that reverses it; no `question.raised`
61
+ goes out for a question the loop answered; and the deferred-gate release #247 built is now
62
+ one closure both the human-answer and the loop-answer paths call, so the second cannot form
63
+ a second opinion about when a gate is worth asking about. It never invents a pick: no
64
+ `Recommended:` line, a letter naming no option, or a block tagged with the new additive §2.7
65
+ keys `irreversible: true` / `money: true` is escalated exactly as before — the fixture that
66
+ proves it goes red the moment the `money:` guard is dropped. `tldrx run questions set
67
+ <stage>:<policy> --note "…"` mirrors `run gates set` on a shared engine, one
68
+ `questions.policy_changed` event, every refusal the gates verb has. The close's decided-tally
69
+ counts `agent-default` apart from "not stated" — it IS stated — and names it only when it is
70
+ non-zero, so every close that recorded none reads as it did.
71
+ - **`tldrx run auto --until-done [<n>]` relaunches the loop in-process after an exit it can do
72
+ nothing else with — a stage failure past `--retry-failed`, a thrown error that used to reach
73
+ `fail()` as a bare 1, a refusal whose remedy is mechanical — at most `n` times (default 5),
74
+ writing `run.relaunched` with the exit it recovered from; never over exit 4 (a person's),
75
+ never over a `budget.blocked` (nothing in-process moves the ceiling, #232/#244), and never
76
+ twice over the same last line. Measured 2026-09-12: five hand relaunches and 18 h before a
77
+ story ran (#252).** The loop body is now `runAutoOnce` and `runAuto` a bounded supervisor over
78
+ it — in the same process, because the loop holds no state and re-reads run.yml every
79
+ iteration, which is exactly what makes "run it again" honest. The run id the first attempt
80
+ resolves is pinned for every relaunch, and so is its spend baseline: "spent by this loop" and
81
+ `--max-usd` describe the one command a person typed, and a relaunch that reset the loop's own
82
+ ceiling would have been a ceiling in name only (a test pins it: `[fail, ok, ok]` under
83
+ `--max-usd 0.80` stops at exit 2 after two spawns, not three). One derivation,
84
+ `relaunchVerdict`, decides both the relaunch and whether the run-end notification is held, so
85
+ an attempt that is relaunched sends no `run.failed` and `run.finished` / `run.failed` go out
86
+ once, from the last attempt. A throw is caught only under the flag — without it the loop is
87
+ byte-identical, pinned against `--until-done 0`, and `fail()` still gets its exit 1. The
88
+ epic-claim-at-cut half of the issue is #262 and lives in the Build executor, not here.
89
+
90
+ - **Build entry proves the Definition of Done can run IN A WORKTREE before a developer is
91
+ paid (#254).** Measured on a live workspace, 2026-09-12 at 0.16.1: **18 h from `run auto` to
92
+ the first story that could run**, five relaunches, two of them environment and both invisible
93
+ to Build entry. The repo's `install:` named `./install.sh`, which existed in the human's
94
+ checkout and was never committed — a `git worktree` carries TRACKED FILES ONLY — so every
95
+ story's install failed identically in its own tree, each time after the story was opened and
96
+ immediately before the paid turn, and each time a person fixed it and relaunched. The base
97
+ pre-flight (#41) was green throughout and was right to be: it measures in the checkout, which
98
+ is where the file is. So Build entry now asks the other question once, before `agent.spawned`:
99
+ one throwaway detached worktree at the base sha, the declared `install:` run inside it, then a
100
+ resolution of each declared DoD command's first token in that tree. **Never the suite** — the
101
+ base pre-flight already measures the suite in the checkout and paying twice for it at every
102
+ entry is a cost nobody asked for. A refusal is exit 2, once, naming the exact path or binary.
103
+ Two decisions are the whole design. The `install:` command's own path token is refused by
104
+ `git ls-files` with NOTHING executed, and that is sound only because of WHEN the install runs:
105
+ nothing has installed anything yet, so a relative path the install NAMES cannot be one it
106
+ PRODUCED. A DoD command's path token gets the opposite treatment — tracked-ness decides
107
+ nothing there, because `node_modules/.bin/vitest` is untracked in every repo on earth and is
108
+ present by the time the DoD runs; it is probed AFTER the install, in that tree, and an
109
+ untracked twin in the checkout is rendered as advice, never as the verdict. The same rule has
110
+ a second half: a `dod` list runs in order, so a path named by any command AFTER the first may
111
+ be an artefact an earlier `dod` command builds (`dod: ["npm run build", "dist/check.sh"]`) —
112
+ the probe runs no suite, cannot tell that from "nobody committed it", and therefore writes an
113
+ advisory instead of a refusal. A bare binary name is never downgraded, at any index: nothing
114
+ a `dod` command does can put one on this process's PATH. Refusing what would have worked is
115
+ the expensive direction for an entry gate, so wherever the two readings could not be told
116
+ apart, this refuses less — each half pinned by a control that reddens when it is removed.
117
+ One existing behaviour moved with it: an install that fails deterministically in a fresh tree
118
+ is now refused at entry instead of blocking story 1 (`story-worktree-deps.test.ts` (d) says
119
+ so and says why). The per-story install is untouched and still answers for an install that
120
+ passes on the base and fails on a story's branch. The result is cached beside the base result in
121
+ `04-build/preflight.yml` under an additive `worktree:` key, so a resumed run does not re-pay
122
+ the install, and the cache is narrowed by three things and not two: the base sha, the
123
+ declaration hash (the install, every command probed, and the whole allowlist — the operator's
124
+ fix is an edit to `workspace.yml` and that edit must not be invisible), and AGE. A green gets
125
+ six hours rather than the base green's forever, because this row is a claim about an
126
+ ENVIRONMENT — the host's PATH, the registry the install reached — and none of that is in the
127
+ sha; a red gets the base red's 30 minutes, because #162 is the filed failure where a row
128
+ measured over a broken environment kept refusing after somebody fixed it. The gate costs
129
+ exactly zero where it could learn exactly nothing: a repo with no `install:` whose DoD names
130
+ only bare binaries opens no worktree at all, since `PATH` is the same in both trees and the
131
+ base pre-flight has already RUN the command there.
132
+
133
+ ### Fixed
134
+
135
+ - **One blocked story no longer stops every later wave — the boundary asks per STORY, from
136
+ `depends_on` (#260).** Measured 2026-09-12 on one workspace at 0.16.1: a loop told to build 8
137
+ stories built 5, never started 3, and reported the stage `done`. `03-plan/waves.yml` scheduled
138
+ W1=[S1] W2=[S2,S5,S6,S7,S8] W3=[S3] W4=[S4]; S7 blocked in W2 and the build skipped W3 and W4
139
+ outright, leaving S3 and S4 at `todo` while S2 — the only thing S3 depends on — was `done`. It
140
+ happened twice on the same run, the second time after a `story reopen S7` and a relaunch. The
141
+ mechanism was one line: after each wave the parallel path asked `waveFailed`, which is
142
+ `stories.some(status === "blocked")`, and broke out of the loop — `depends_on` appeared **zero
143
+ times** in `executors/build.ts`, so the one fact that could tell "a story that needs the
144
+ blocked code" from "a story that never did" was never read, though `validatePlan` already
145
+ guarantees every dependency sits in an earlier wave and is therefore settled at that moment.
146
+ The rule the `break` defended is kept, and it is the reason this is a frontier and not a
147
+ deletion: **a story runs when every one of its `depends_on` is `done`**, and a story whose
148
+ dependency did not land is NOT attempted — nothing fans out over code that was not landed,
149
+ which is how one red story becomes N of them. What changes is that the rule stops applying to
150
+ stories that never needed that code. A story held back is now `blocked` WITH the reason,
151
+ `dependency S7 blocked` — absent-with-reason, never a silent `todo` — and that sentence is
152
+ what `blocked_reason` on `gate.requested` and the continue note read, so the gate stops being
153
+ "held by stories" with nothing a person or a turn can act on (the #239 shape, one layer down).
154
+ It is transitive for free: S4 behind a blocked S3 reads `dependency S3 blocked`. The documents
155
+ stop hiding it too — the stage line counts every story `waves.yml` scheduled instead of the
156
+ rows the executor happened to hold (`5 of 8`, never `5 of 5`), the skipped stories are named
157
+ on stdout with the dependency each waits on instead of one line that said the next wave "may"
158
+ have depended on something, and the handoff's `none — every scheduled story reached done` is
159
+ now decided against `waves.yml`: a scheduled story with no outcome at all is named in
160
+ `## Unknowns` rather than dropped, which is the record that was lying in the dangerous
161
+ direction (§7). **`--parallel 1` is untouched**: the sequential path never stopped a later
162
+ story, a story whose dependency blocked is still attempted there, and this frontier replaces
163
+ the parallel path's stop only — turning today's finished stories into blocked ones would be a
164
+ second change, in the opposite direction. Both directions are pinned by tests that are the
165
+ same fixture with one field moved, and the existing test that froze the defect as correct (an
166
+ independent S4 stopped by a blocked S2) was rewritten to give S4 the `depends_on` that makes
167
+ its assertion true for the reason the code's own comment gives — argued in the test, never
168
+ flipped in silence.
169
+
170
+ - **A fix-list finding now declares what it IS, and a documentation defect stops holding a story
171
+ (#255).** Measured 2026-09-12 across two workspaces at 0.16.1: three stories with a green
172
+ definition of done, a merged commit and an APPROVING reviewer settled `blocked` — every one of
173
+ them on a `fix-now` finding whose whole content was a stale docstring or an `[src: …]` line
174
+ that did not resolve. The reviewer was not wrong: the prompt says `fix-now` is "this story's
175
+ own correctness", and a stale docstring in the story's own files honestly reads that way. What
176
+ the record could not say was that the defect was TEXT — a disposition answers "where does this
177
+ go" and nothing answered "what is it" — so a blocked story held the Build gate's `stories`
178
+ condition and three unattended nights ended at a person. `severity` was no help and is not the
179
+ vehicle: it was a free string defaulted to `unrated` that NO consumer read (grep over `src`:
180
+ one render site), so the effect was binary, `fix-now` or nothing. Every finding now carries a
181
+ required `kind` — `correctness` and `security` are behaviour and hold the story exactly as
182
+ before, `docs` and `style` are the way the repo reads and do not — and a `docs`/`style` finding
183
+ submitted `fix-now` is routed to `defer-with-log`, staying in the artefact, in the PR body and
184
+ on `retro.md` with a `Normalised-from: fix-now` line saying what it was submitted as. Nothing
185
+ is lost; it just stops costing a night.
186
+ This makes a gate WEAKER on purpose, so both directions are priced and both are tested.
187
+ Unblocking is not free: declaring a `fix-now` finding `docs` costs the SAME `[src: …]` citation
188
+ `refuted` already costs, through the same §2.8 parser (owner decision, 2026-09-13) — one exit,
189
+ one rule. The sentence that forced it is "the docstring says cents, the code returns dollars":
190
+ its own words do not say which side is wrong, and a one-word field deciding that a money bug is
191
+ a typo is the one failure with no log line to find it by, so the reviewer cites the behaviour
192
+ that makes it harmless or leaves it `correctness`. When in doubt it blocks: a `kind` that is
193
+ absent, unreadable or outside the enum is refused — for FREE, indexed as a fault in the report
194
+ so it costs the story no attempt, because a reviewer that forgot a field did not do bad work.
195
+ Reading is tolerant the other way (§7): a fix list already on disk with no `Kind:` line, or one
196
+ whose `Kind:` does not narrow to the enum, reads as *not stated*, and not stated blocks.
197
+ The four reviewer-prompt goldens MOVED, and that is the change, not an accident: the schema
198
+ alone would have left a reviewer nobody told about the new field, so the prompt's own prose
199
+ explains it, and `test/build-golden.test.ts` froze that prose. The diff is exactly eleven
200
+ inserted lines at one insertion point in each of the four reviewer prompts and nothing else — no
201
+ event, no `run.yml` row, no exit code. The prose teaches only the JUDGEMENT (which of the four
202
+ words is true of the defect) and never spells the field name: gh #133's guard derives that list
203
+ from `REVIEW_SCHEMA` itself, and it caught the first version of this wording.
204
+
205
+ - **The spawned developer can delete a file, and a permission refusal is now a recorded reason
206
+ instead of two burned attempts (#261).** Measured 2026-09-12 on one workspace at 0.16.1: story
207
+ S7, "delete an unused file", was undoable. The developer's allowance carried no verb that
208
+ removes a path — `Write`/`Edit` can empty a file, nothing could unlink it or take it out of
209
+ the index — so every `git rm` and `git mv` it tried came back "This command requires
210
+ approval", which in a headless `-p` run is a prompt nobody is there to answer. Worse was what
211
+ the framework did with that: a refused turn exits 0 and returns an envelope, so the DoD ran
212
+ green on an untouched tree, the empty commit went to review, the reviewer faulted a diff that
213
+ was never written, and the SECOND attempt bought the same wall — the developer's own sentence
214
+ living only in `result.raw.json`, in no event, no story file and no handoff. Two halves, and
215
+ the second is the one that generalises. The allowance grows the file-lifecycle git verbs —
216
+ `Bash(git rm *)`, `Bash(git mv *)`, `Bash(git restore *)`, the space form, never a bare `rm`
217
+ — because they are index operations on the story's OWN tree, undone by exactly the
218
+ `git checkout` that undoes an `Edit`, on a branch that never leaves the machine (`git push`
219
+ stays asserted absent); the 2026-08-29 audit's line — no permission-free shell, only git verbs
220
+ — is the reason the three are git verbs and is unchanged. And a tool call refused for approval
221
+ now BLOCKS the story at once with `` permission — `<command>` `` and why, one string reaching
222
+ the story file, the handoff's `## Unknowns` and `gate.requested`'s `blocked_reason`, so the
223
+ next verb that is missing costs one attempt and says so instead of two and nothing. What the detector
224
+ reads is a structural field first — `tool_result_meta[].non_execution_kind: "user-rejected"`, measured
225
+ on `claude` 2.1.270, present on both refusals measured and absent on every command that ran, including
226
+ one the layer allowed and git itself failed — and the `requires approval` sentence only as a fallback,
227
+ and only on a `Bash` call whose result errored. That fence exists because pre-merge review measured the
228
+ unfenced version reading a plain `Read` of a file CONTAINING the phrase as a refusal: this CHANGELOG is
229
+ one of the files that contains it, and the block happens before the DoD and the commit, so a false
230
+ positive would discard real work and spend the attempt. A refusal carrying neither signal is a miss
231
+ taken on purpose, and the sentence half depends on prose the host writes and can change without notice. What the
232
+ new grant covers, measured against `claude` 2.1.270 with `Bash(git rm *)` as the only rule:
233
+ `git rm -r`, `git rm -rf` and `git rm -r -- .` all pass (a trailing `*` matches the whole
234
+ argument tail, flags included); a path outside the repo is refused by GIT, not by the rule;
235
+ `git -C <elsewhere> rm` is refused by the rule, since the command does not begin `git rm`.
236
+ The open question about the same grammar, **#215** — does an `allow` rule of the
237
+ `Bash(<cmd> *)` form reach a command substitution in its arguments? — was measured alongside
238
+ this, `claude` 2.1.270, with `git rm -n` as a non-destructive instrument: under
239
+ `Bash(git rm *)`, `git rm -n -- "$(echo MARKER.txt)"`, `git rm -n "$(echo MARKER.txt)"` and
240
+ `git rm -n -r "$(echo .)"` were **all denied** ("Contains shell syntax that cannot be
241
+ statically analyzed"), while a bare `git rm MARKER.txt` ran. **The nuance matters more than
242
+ the result: the layer refuses the SYNTAX of a substitution, not the action** — in the same
243
+ measurement the agent rewrote the command with the value already expanded, and then it ran.
244
+ For `git rm <path>` that expanded form is exactly what this grant hands out, so the direction
245
+ is the safe one; nobody should build a guarantee on "substitutions are blocked", because what
246
+ is blocked is a spelling. And all of it is HOST behaviour, in the agent CLI's permission
247
+ layer, which can change without a line of tldrx moving — which is why the argument that holds
248
+ this grant up is the tree-scope one and not the permission layer's manners.
249
+ - **`run.yml` can finally explain its own `cost_usd`, and a Build turn stopped losing its
250
+ accounting entirely (#222).** A field audit across three workspaces measured a row reading
251
+ `input_tokens: 84, output_tokens: 37150, cost_usd: 1.98` for a 124 KB prompt — while the
252
+ provider's own result for that same turn reported `cache_read_input_tokens: 4911750`. 84 plus
253
+ 37,150 tokens do not cost $1.98; the 4.9 M cache reads do, and nothing on the row could say
254
+ so. Across 101 surviving transcripts in one workspace, cache reads were 97.3% of the input
255
+ side and the ledger's column saw 0.002% of it. The counters were parsed on every turn
256
+ (`spawnAgent.ts`) and thrown away one function later. They are now RECORDED: additively on the
257
+ task row, and — the half the issue did not know about — on the `agent.result` of the executor
258
+ path, which emitted **no `usage` at all**, so every Build and Watch turn lost all four
259
+ counters rather than only the two cache ones (measured: `grep -c usage` over the four frozen
260
+ golden `*-events.txt` returned 0, 0, 0, 0 with ten `agent.result` lines among them, and
261
+ `costView.ts`'s `toAttempt` therefore priced a whole Build off `{0,0,0,0}`). Nothing is
262
+ displayed that was not displayed before: this is a fix to the RECORD, and the dashboard and
263
+ `tldrx cost` are byte-identical. Two decisions worth naming. The cache counters are gated one
264
+ at a time rather than under `tokenSplit`'s both-or-nothing rule, because `input`+`output` are
265
+ two halves of one total that `turnTokens` adds and the cache counters are separate quantities
266
+ at separate prices (1.25x and 0.1x an input token) that nothing adds — and the spec's own
267
+ measured pair of calls is the proof that a paired rule would be wrong: the second reported
268
+ `cache_creation: 0` beside 37,059 cache reads, the single most useful number on the row. And
269
+ `turnTokens` names them in its input type while deliberately reading neither, so the exclusion
270
+ is a decision on the record rather than a field nobody has noticed yet: it returns one
271
+ unlabelled `number`, and folding 4.9 M cache reads into an `input_tokens: 84` would make that
272
+ number a total in no currency at all. `ExecutorTask` now carries the provider's `usage` whole
273
+ instead of a hand-copied two-field subset, which is what let the cache counters be dropped at
274
+ that seam in the first place, and the event block has one spelling for all three emitters
275
+ (`envelope.ts`'s `usagePayload`). A row from before this exists reads absent, as it should:
276
+ nothing reconstructs a counter nobody wrote down.
277
+
278
+ - **A gate about to sign itself is no longer sent as a Yes/No the owner cannot answer (#247).**
279
+ #203 holds back the `gate.requested` notification while open questions are the only thing
280
+ holding an `auto` gate, and released it when the answers landed if the gate was still
281
+ `pending`. Under `--wait-gates` that test is true by construction: the only thing that
282
+ self-closes an auto gate mid-wait runs one iteration later, inside the gate wait, after the
283
+ loop has already decided. Measured 2026-09-12 on two live workspaces, 3 of 3 questioned
284
+ stages: the Yes/No went out 600 ms after the last answer and the gate signed itself on the
285
+ very next poll — so the one tap the message invited ran `approve` on an already-approved gate,
286
+ and because the in-wait `gate.approved`/`stage.done` fall between two notify windows nothing
287
+ ever arrived to settle it: the bridge kept the prompt open and re-mentioned its owner every
288
+ escalate tick for two hours. *"esa pregunta está de más, es confusa."* The release now asks
289
+ the gate's CONDITIONS rather than its status — the same `reevaluateAutoGate` the poll uses,
290
+ extracted so the poll and the release read one measurement and neither can form a second
291
+ opinion (§7) — together with the policy that will act on them. Every condition holding and a
292
+ `--wait-gates` to sign it means there is no decision to take: nothing is sent, and the run
293
+ says which stage and why on stdout rather than going quiet. A gate something else still holds
294
+ is notified exactly as #203 promised, and now worded from that one reading: the re-measured
295
+ verdict supplies the summary's `held by:` sentence and the open questions are read in the same
296
+ breath, so a payload can no longer name four open questions beside `holding: "none"` — which
297
+ is what the adapter was handed, measured, and why it drew the default two buttons. #239 fixed
298
+ WHICH command a questions-held gate offers; this is WHEN, and whether, it is offered at all.
299
+ - **A turn that wrote a lot of files could not be RECORDED, and the invocation died owing the
300
+ money it had just spent (#248).** Measured on a live headless run: `tldrx run auto … --retry-failed 2`,
301
+ eight stories, dead at *"refusing to append an invalid event: payload 4135 bytes exceeds the
302
+ 4096 byte cap"*. The field was `outputs` — every run-relative path the turn wrote, 3924 bytes of
303
+ it under a 212-byte envelope — on the `agent.result` that `recordExecutorTasks` appends. What
304
+ that one refused append cost is the reason this is three fixes and not one. The append was
305
+ RAW, so it never reached the capped seam; the seam would not have saved it anyway, because
306
+ `capPayload` knew how to name exactly one absence and it was `detail`, which an `agent.result`
307
+ does not have. The throw landed one line AFTER the try/catch #160 built for precisely this, so
308
+ it escaped `runNext` entirely and came out of the CLI as exit 1 — a code `--retry-failed` does
309
+ not retry, since its branch keys on exit 5. A loop told to survive two failures survived zero.
310
+ And `claimEpicBranches` had put the run's epic branch in memory one line before the throw, with
311
+ the `store.save()` after it: the branch was cut, on disk, and `run.yml` did not say so, so the
312
+ relaunch read its OWN epic as a stranger's and refused to stack onto it — with the only way
313
+ back in being `tldrx next --reuse-epic`, which `run auto` does not expose. So: `capPayload`
314
+ learns `outputs`, and names it by COUNT (`outputs_omitted: 80`) with the full list written
315
+ beside the event at `<phase>/log/overflow/…-agent.result-outputs.txt` — never a truncated list,
316
+ which reads downstream as the whole one. The capped seam moves out of `runExecutor` to module
317
+ scope and all four raw `agent.result` appends route through it, not just the one that was hit:
318
+ all four carry `outputs` (measured), and the other three had simply not been reached yet. A
319
+ throw while recording rows now fails the STAGE with exit 5, in the same family as the executor's
320
+ own throw, and says how many of how many rows reached `run.yml` rather than a boolean that
321
+ would be false for a partial truth. It repaints no row: those turns finished, and only their
322
+ event failed to land. And a claim is saved the moment it is earned. `capPayload` still knows
323
+ its two fields BY NAME and has no general rule for shrinking whatever is biggest — a third
324
+ growable payload field has to be taught to it, deliberately, or it is refused whole.
325
+
3
326
  ## 0.17.0 — 2026-09-12
4
327
 
5
328
  ### Added
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.18.0 | 2026-09-13 | `beta` | the release that lets an unattended run survive its own accidents and end somewhere, every item measured on live runs at 0.16.1 rather than reasoned about: a turn that wrote a lot of files could not be RECORDED — an `agent.result` whose `outputs` field held 3924 bytes of run-relative paths blew the 4096-byte event cap through a RAW append that never reached the capped seam, the throw landed one line after the try/catch built for exactly this and came out as exit 1, which `--retry-failed` does not retry since its branch keys on exit 5, so a loop told to survive two failures survived zero, and the epic branch had been cut on disk one line before the throw with the save after it, leaving the relaunch to read its own epic as a stranger's; `outputs` is now named by COUNT with the full list written beside the event rather than truncated, all four raw appends route through the capped seam at module scope, a throw while recording fails the STAGE with exit 5 saying how many of how many rows landed, the claim is saved the moment it is earned, and `capPayload` still knows its two fields BY NAME — a third growable field has to be taught to it deliberately or it is refused whole; Build entry now proves the Definition of Done can run IN A WORKTREE before a developer is paid, after 18 h from `run auto` to the first story that could run on one workspace, five relaunches, because the repo's `install:` named a file that existed in the human's checkout and was never committed and `git worktree` carries TRACKED FILES ONLY, so every story failed identically after the story was opened and immediately before the paid turn while the base pre-flight was green and right to be, measuring in the checkout where the file is — one throwaway worktree at the base sha, the install run inside it, each DoD command's first token resolved there, NEVER the suite, refusal exit 2 naming the exact path or binary, cached under an additive `worktree:` key narrowed by base sha, declaration hash and AGE (six hours for a green, because the row is a claim about an ENVIRONMENT that no sha contains; the base red's 30 minutes for a red), and costing zero where it could learn nothing; where the two readings could not be told apart it deliberately refuses LESS — a path named by a `dod` command after the first may be an artefact an earlier one builds, so that is an advisory and not a verdict; one blocked story no longer stops every later wave, after a loop told to build 8 stories built 5, never started 3, and reported the stage `done`, twice on the same run, because the parallel path asked only "did any story block" and `depends_on` appeared ZERO times in the Build executor — a story now runs when every one of its `depends_on` is `done`, one held back is `blocked` WITH the reason `dependency S7 blocked` (transitive for free) which is what the gate's `blocked_reason` and the continue note read, the stage line counts every story `waves.yml` scheduled rather than the rows the executor happened to hold, and a scheduled story with no outcome at all is named in `## Unknowns` instead of dropped, while `--parallel 1` is untouched on purpose — a story whose dependency blocked is still attempted there, and changing that would be a second change in the opposite direction; the spawned developer can now delete and rename paths in its own tree, after a story that was simply "delete an unused file" proved undoable — nothing in the allowance could unlink a path or take it out of the index, so every `git rm` came back needing approval nobody was there to give, the refused turn exited 0 with an envelope, the DoD ran green on an untouched tree and the reviewer faulted a diff that was never written, twice — the file-lifecycle git verbs are granted as index operations on the story's OWN tree (never a bare `rm`, `git push` still asserted absent), and a tool call refused for approval now BLOCKS the story at once with the command and why, reaching the story file, the handoff and the gate, so the next missing verb costs one attempt instead of two and nothing; that detector reads a structural field first and the English refusal sentence only as a fenced fallback, because pre-merge review measured the unfenced version reading a plain file-read of a document CONTAINING the phrase as a refusal — this very CHANGELOG is one of them — and a refusal carrying neither signal is a miss taken on purpose, with the sentence half depending on prose the host can change without notice; a fix-list finding now declares what it IS, after three stories with a green definition of done, a merged commit and an APPROVING reviewer settled `blocked`, each on a `fix-now` finding whose whole content was a stale docstring or a citation that did not resolve — the reviewer was not wrong and `severity` was never the vehicle (a free string NO consumer read, one render site under `src`), so a required `kind` splits behaviour from text: `correctness` and `security` hold the story exactly as before, a `docs`/`style` finding submitted `fix-now` is routed to `defer-with-log` with a `Normalised-from:` line, and because this makes a gate WEAKER on purpose the unblocking is priced — declaring a finding `docs` costs the SAME `[src: …]` citation that refuting one already costs, a `kind` that is absent or outside the enum is refused for free and indexed as a fault so it costs the story no attempt, a fix list already on disk with no `Kind:` line reads as not stated and not stated blocks, and the four reviewer-prompt goldens MOVED because the schema alone would have left a reviewer nobody told about the field; `run.yml` can finally explain its own `cost_usd` — a row read `input_tokens: 84, output_tokens: 37150, cost_usd: 1.98` for a 124 KB prompt while the provider reported 4,911,750 cache reads for that same turn, and across 101 transcripts in one workspace cache reads were 97.3% of the input side against a column that saw 0.002% of it — the counters were parsed on every turn and thrown away one function later, and the half the issue did not know about is that the executor path's `agent.result` emitted NO `usage` at all, so every Build and Watch turn lost all four counters and the whole Build was priced off zeroes (measured: zero `usage` lines across the four frozen goldens with ten `agent.result` among them); they are recorded additively and gated one at a time rather than both-or-nothing, `turnTokens` names them while deliberately reading neither so the exclusion is a decision on the record, nothing displayed changed — the dashboard and `tldrx cost` are byte-identical — and a row from before this reads absent, since nothing reconstructs a counter nobody wrote down; a gate about to sign itself is no longer sent as a Yes/No the owner cannot answer, after 3 of 3 questioned stages on two live workspaces sent the prompt 600 ms after the last answer and self-signed on the very next poll, so the one tap it invited ran `approve` on an already-approved gate and, because the in-wait approval fell between two notify windows, the bridge kept the prompt open and re-mentioned its owner every escalate tick for two hours — the release now asks the gate's CONDITIONS through the same re-evaluation the poll uses, extracted so neither can form a second opinion, sends nothing when there is no decision to take and says which stage and why on stdout instead of going quiet, and words a genuinely held gate from that one reading so a payload can no longer name four open questions beside `holding: "none"`; and three things a run could not do before: it can end in a pull request toward main on purpose, frozen at `run new --ship` with `push`, `pr` or `merge` into an additive `run.yml` block and not a sixth phase, pushed through the ONE push wrapper in the codebase with a PR that reports NO check left open and recorded as `merge: absent — no checks to wait on` because auto-merge over nothing to wait on is a merge now rather than a merge when green — pre-merge review caught the first version erasing a recorded merge failure with an empty string and exit 0, and the emitter dropped the whole block until it was taught the key; `questions_policy: recommended` answers a blocking question that carries its own `Recommended:` line and escalates the ones that do not, after 10 owner questions across two headless runs ($89.82 over 28 h and $26.38 over 5.6 h, the agents busy 4.9 h and 56 min of it) parked the loop on 9 that already held the pick in a parsed field, writing `decided_by: agent-default` as a THIRD value beside owner and driver because `owner` there would be an audit record lying in the dangerous direction, and never inventing a pick — no line, a letter naming no option, or a block marked irreversible or money escalates exactly as before; and `run auto --until-done [<n>]` relaunches the loop in-process after an exit it can do nothing else with, at most `n` times (default 5), after five hand relaunches and 18 h before a story ran — never over a person's exit 4, never over a budget block since nothing in-process moves a ceiling, never twice over the same last line, with the run id and the spend baseline pinned across relaunches so `--max-usd` still describes the one command a person typed; the epic-claim-at-cut half of that issue lives in the Build executor and is NOT fixed here |
319
320
  | 0.17.0 | 2026-09-12 | `beta` | three things the framework knew and recorded wrongly, each found by reading its own records rather than by using it: a gate notification now carries the condition HOLDING the gate as data instead of leaving it to be guessed from the text of the command it suggests — `holding` says questions, stories or nothing-mechanical, and when a story is blocked with a recorded reason the payload also hands over a ready-made rejection that lets the loop carry on, with the note DERIVED from the blocked story rather than canned, because a rejection's note is fed to the next turn's prompt and a generic one would hand that turn an empty instruction, so when no reason can be derived the offer is absent rather than invented; the emitted command keeps a literal placeholder and never interpolates the reason, which keeps the quoting hazard out of the record and puts it where a substituting client can see it; `run status` stopped printing more money left than it had ceiling — the run ceiling now has ONE live copy, read from `budget.yml` by every live screen, and `run.yml`'s mirror is documented as the creation value and no longer written by a raise, since the break was never that a raise failed to write the mirror but that an ordinary concurrent save carried a stale copy over it, and the fix was to delete the half-sync rather than to build a better one — pre-merge review caught the first version pairing a LIVE spend with that now-frozen ceiling inside `tldrx replay`, which would have reproduced the same impossible line in a different command on every run whose budget had been raised, measured on the real CLI as `$12.00 spent of $10.00 ceiling` with no concurrency required; and a task row now records the ROLE its turn actually ran under, where every Build turn had been filed as the developer including the reviewer's — the role was known at spawn and written to the event stream, and was dropped on the way to the ledger, so the audit record named the wrong actor for work it had itself measured; the new key is additive and written on EVERY row including the developer's, because a role present only on reviewers would make its absence mean developer-or-not-recorded and send a reader back to inferring the role from an absence, which is the guess the change exists to remove |
320
321
  | 0.16.1 | 2026-09-12 | `beta` | five things the framework knew and did not say, or said wrong — four of them found by using it rather than by reading it: a red base pre-flight now KEEPS its output, so a refusal that blocks every story in a Build names the failing test and cites the file, where it used to record only the last line of stdout — measured 2026-09-10, a stage refused with `tail: "Test run completed with non-success exit code: 2"` while the cause, `DockerUnavailableException`, sat on line 12 of 163,702 lines the run had already captured and thrown away, so diagnosing a refusal the framework had itself measured meant re-running the workspace's test command by hand; it now routes through the same seam #211 built for a story's DoD, which had been naming its failing test correctly all along on the same command, the same day, in the same repo — the path with the SMALLER blast radius was the legible one; a gate notification now offers the command that CLEARS it rather than always `tldrx approve` — questions open give `tldrx answer <id>`, unfinished stories give `tldrx run status`, and `approve` is offered only when nothing mechanical is outstanding, after an owner approved a Build gate by mistake twice in one evening over unbuilt stories, each time from a phone, each time needing a revoke, while a ten-minute heartbeat repeated `Run: tldrx approve` seven times under a sentence that correctly named the five open questions holding it; `tldrx reject --and-continue` lets a rejection mean "redo it this way and carry on" instead of ending the run — the loop resumed after an approve and stopped after a reject, so the button meaning "there is still work to do" was the one that stopped the work and only a terminal could revive it; five real rejections that night all meant continue, five cost a manual relaunch, and a bare `tldrx reject` still writes a byte-identical `run.yml` and stops exactly as before; the expert-recompute fixture anchors its evidence dates to a `now` it can move, so `bun test` stops going red by the calendar — pristine `main` was red at the exact sha of the published 0.16.0 with no commit in between, and a clock moved one year forward reddened FIVE cases, not the one that had already fired; and `test/merge-wave.test.ts`'s concurrency failure, documented as a known flake since #115 and carrying a written licence to re-run it, was never one: `merge-guard.sh` rewrote `.git/hooks/reference-transaction` IN PLACE while a sibling wave's `git merge` was exec'ing it — ETXTBSY on Linux at 31% under contention, benign on macOS, which is why it was green locally and red in CI, and why a same-sha re-run failed 2 for 2 rather than passing; the hook is now written to a temp file and RENAMED into place, the refusal that used to borrow `2`/`merge conflict` for a hook abort now says what it was and exits 11, and AGENTS.md §4 withdraws the re-run licence for those two cases while naming the interrupted-merge case (#237) as still open and undiagnosed — because "all real" for a whole file costs the same as "all flake", in the other direction |
321
322
  | 0.16.0 | 2026-09-11 | `beta` | an unattended run can now clear the one kind of failure it was stopping on, and a gate that refuses says why it refused: measured 2026-09-10 on a real unattended `run auto`, the loop drove itself through what → how → plan and signed all three `auto` gates by itself, and still needed a person four times — three of those were content or money decisions a loop must not make, and the fourth was a plan that failed its own check by five characters over a cap, where a person relaunched the same command and the next attempt fixed the two files and passed, so the loop stopped on the one failure it could have cleared; `tldrx run auto --retry-failed <n>` now runs a failed stage again at most `n` times in a row, bounding exit `5` and nothing else — a usage error (`1`), a money refusal (`2`) and an awaiting-human park (`4`) are each attempted ONCE however large `n` is, because a phase ceiling means a human decides about money and a retry would turn that sentence into a delay — only CONSECUTIVE failures count since what is bounded is "this run is stuck" and not "this run has ever failed", a retry SPENDS as a fresh metered stage under the same phase ceiling and the same `--max-usd`, `0` is the default and a default invocation's lines are byte-identical to what they were, and when the bound is spent the loop stops on the failure's own exit `5` and says the count LAST, so the sentence that reaches a phone is what the loop tried and not a bare number; and an `auto` gate that REFUSES now writes down the verdict its note was always designed to carry — a gate sat pending ~40 minutes while `run status` and `--verbose` named no condition at all, and the reason surfaced only when a person guessed at the `tldrx approve` the status line suggested, which is the one route nobody unattended is going to take — recording all seven conditions WITH THEIR VALUES on the still-`pending` gate, since a note that dropped the passing ones would answer "was it the money" with the same silence, and naming the holding ids on the gate row and on the `waiting` line; it writes only over a `pending` gate, so a gate a person has since signed keeps THEIR words, and only when the verdict would change, so a four-hour `--wait-gates` poll writes once per distinct verdict rather than thousands of times — and that test and that write are a compare-and-set under the workspace lock, because pre-merge review reproduced, with two real processes, a check-then-act over an earlier snapshot erasing a concurrent `approve` outright, and the poll runs every two seconds precisely while a person is deciding |
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  conflictOf
4
- } from "./chunk-tj66vg1n.js";
4
+ } from "./chunk-7hhjx1wk.js";
5
5
  import {
6
6
  FactsStore,
7
7
  formatJaccard
8
- } from "./chunk-54vzevgt.js";
8
+ } from "./chunk-26xkmfdr.js";
9
9
  import {
10
10
  parseHookInput,
11
11
  readStdin
@@ -13,12 +13,12 @@ import {
13
13
  import {
14
14
  EventLog,
15
15
  PHASE_ID_RE
16
- } from "./chunk-jp6jscsd.js";
16
+ } from "./chunk-00h4xah7.js";
17
17
  import {
18
18
  PHASE_IDS
19
- } from "./chunk-d0rp8c68.js";
20
- import"./chunk-k4nqzdw5.js";
21
- import"./chunk-nqwg5ddj.js";
19
+ } from "./chunk-bm9aqtg3.js";
20
+ import"./chunk-xxcy0tsa.js";
21
+ import"./chunk-ab6cv5v5.js";
22
22
  import {
23
23
  ADVISORY_KEY,
24
24
  MAX_FACT_CHARS,
@@ -28,7 +28,7 @@ import {
28
28
  renderQuestionBlock,
29
29
  replaceBlock,
30
30
  serializeQuestions
31
- } from "./chunk-fm141jak.js";
31
+ } from "./chunk-p0ns6fex.js";
32
32
  import {
33
33
  ITERATION_ONLY_SLOT,
34
34
  PROJECT_FRAMEWORK_DIR,
@@ -407,7 +407,11 @@ function answerProvenance(block, overrides, repoNames, fallbackRepos) {
407
407
  return {
408
408
  repos,
409
409
  unresolved: named.unresolved,
410
- source: override?.decidedBy === undefined ? {} : { decided_by: override.decidedBy }
410
+ source: override?.decidedBy === undefined ? {} : { decided_by: override.decidedBy },
411
+ row: {
412
+ ...override?.alternatives === undefined || override.alternatives.length === 0 ? {} : { alternatives: override.alternatives },
413
+ ...override?.recommendedWhy === undefined ? {} : { recommended_why: override.recommendedWhy }
414
+ }
411
415
  };
412
416
  }
413
417
  function unresolvedEntries(captured) {
@@ -445,6 +449,7 @@ function captureAnswers(questionsPath, ctx) {
445
449
  fact: text,
446
450
  ...truncated ? { truncated: true } : {},
447
451
  ...clash === null ? {} : { conflicts_with: [clash.fact.id] },
452
+ ...prov.row,
448
453
  area,
449
454
  repos: prov.repos,
450
455
  kind: "answer",
@@ -22,10 +22,10 @@ import {
22
22
  validateRunBudget,
23
23
  wouldExceed,
24
24
  wouldExceedHostTokens
25
- } from "./chunk-3kmx3dmz.js";
25
+ } from "./chunk-rw9zk47w.js";
26
26
  import {
27
27
  EventLog
28
- } from "./chunk-jp6jscsd.js";
28
+ } from "./chunk-00h4xah7.js";
29
29
  import {
30
30
  cursorStage,
31
31
  hostTokensIn,
@@ -35,10 +35,10 @@ import {
35
35
  renderRunEconomies,
36
36
  runSpend
37
37
  } from "./chunk-4mjxyfp9.js";
38
- import"./chunk-k4nqzdw5.js";
38
+ import"./chunk-xxcy0tsa.js";
39
39
  import {
40
40
  noteDeprecations
41
- } from "./chunk-fm141jak.js";
41
+ } from "./chunk-p0ns6fex.js";
42
42
  import {
43
43
  PROJECT_WORK_DIR,
44
44
  findWorkspaceRoot,
@@ -1,7 +1,8 @@
1
1
  import {
2
2
  GATE_POLICIES,
3
- validateGatesPolicy
4
- } from "./chunk-k4nqzdw5.js";
3
+ validateGatesPolicy,
4
+ validateStagePolicy
5
+ } from "./chunk-xxcy0tsa.js";
5
6
  import {
6
7
  asDocument,
7
8
  isRecord,
@@ -27,6 +28,7 @@ var EVENT_TYPES = [
27
28
  "run.unlocked",
28
29
  "run.cancelled",
29
30
  "run.attended",
31
+ "run.relaunched",
30
32
  "phase.started",
31
33
  "phase.done",
32
34
  "stage.started",
@@ -44,6 +46,7 @@ var EVENT_TYPES = [
44
46
  "gate.rejected",
45
47
  "gate.revoked",
46
48
  "gate.policy_changed",
49
+ "questions.policy_changed",
47
50
  "story.reopened",
48
51
  "story.base_fastforwarded",
49
52
  "story.review_retried",
@@ -222,6 +225,12 @@ function skippedNote(skipped) {
222
225
  }
223
226
  var WARNED = new Set;
224
227
 
228
+ // src/core/run/questionsPolicy.ts
229
+ var QUESTION_POLICIES = ["human", "recommended"];
230
+ function validateQuestionsPolicy(value, stageIds, issues) {
231
+ validateStagePolicy(value, stageIds, issues, { field: "questions_policy", policies: QUESTION_POLICIES });
232
+ }
233
+
225
234
  // src/core/plan/branchModel.ts
226
235
  var BRANCH_MODELS = ["per-epic", "integration"];
227
236
  function isBranchModelKind(value) {
@@ -429,6 +438,9 @@ function describe(value) {
429
438
  // src/core/run/duration.ts
430
439
  var DURATION_BASES = ["spawned", "prepare-to-commit"];
431
440
 
441
+ // src/core/run/shipPolicy.ts
442
+ var AUTO_MERGE_POLICIES = ["never", "checks"];
443
+
432
444
  // src/core/run/RunFile.ts
433
445
  var STAGE_STATUSES = [
434
446
  "pending",
@@ -599,6 +611,32 @@ function validateRunFile(input) {
599
611
  if (doc[key] !== undefined)
600
612
  requireString(doc[key], key, issues);
601
613
  }
614
+ if (doc.ship !== undefined) {
615
+ if (isRecord(doc.ship)) {
616
+ requireKeys(doc.ship, ["push", "pr", "auto_merge"], "ship", issues);
617
+ for (const key of ["push", "pr"]) {
618
+ if (doc.ship[key] !== undefined && typeof doc.ship[key] !== "boolean") {
619
+ issues.push({ path: `ship.${key}`, message: `expected a boolean, got ${typeof doc.ship[key]}` });
620
+ }
621
+ }
622
+ requireEnum(doc.ship.auto_merge, AUTO_MERGE_POLICIES, "ship.auto_merge", issues);
623
+ if (requireArray(doc.ship.pr_urls, "ship.pr_urls", issues)) {
624
+ doc.ship.pr_urls.forEach((url, i) => requireString(url, `ship.pr_urls[${i}]`, issues));
625
+ }
626
+ requireString(doc.ship.merge, "ship.merge", issues);
627
+ if (doc.ship.merges !== undefined) {
628
+ if (isRecord(doc.ship.merges)) {
629
+ for (const [name, state] of Object.entries(doc.ship.merges))
630
+ requireString(state, `ship.merges.${name}`, issues);
631
+ } else {
632
+ issues.push({ path: "ship.merges", message: "expected a mapping of repo name to merge state" });
633
+ }
634
+ }
635
+ requireString(doc.ship.shipped_at, "ship.shipped_at", issues);
636
+ } else {
637
+ issues.push({ path: "ship", message: "expected a mapping" });
638
+ }
639
+ }
602
640
  if (doc.keep_worktrees !== undefined && typeof doc.keep_worktrees !== "boolean") {
603
641
  issues.push({ path: "keep_worktrees", message: `expected a boolean, got ${typeof doc.keep_worktrees}` });
604
642
  }
@@ -618,6 +656,7 @@ function validateRunFile(input) {
618
656
  }
619
657
  }
620
658
  validateGatesPolicy(doc.gates_policy, declaredStageIds, issues);
659
+ validateQuestionsPolicy(doc.questions_policy, declaredStageIds, issues);
621
660
  let stageCount = 0;
622
661
  let taskCount = 0;
623
662
  let running = 0;
@@ -715,7 +754,12 @@ function validateRunFile(input) {
715
754
  if (task.dedupe !== undefined && typeof task.dedupe !== "string") {
716
755
  issues.push({ path: `${tp}.dedupe`, message: "expected a string" });
717
756
  }
718
- for (const key of ["input_tokens", "output_tokens"]) {
757
+ for (const key of [
758
+ "input_tokens",
759
+ "output_tokens",
760
+ "cache_creation_input_tokens",
761
+ "cache_read_input_tokens"
762
+ ]) {
719
763
  const value = task[key];
720
764
  if (value === undefined)
721
765
  continue;
@@ -3,9 +3,10 @@ import {
3
3
  withWorkspaceLock,
4
4
  workspaceRootOfFactsPath,
5
5
  writeAtomic
6
- } from "./chunk-nqwg5ddj.js";
6
+ } from "./chunk-ab6cv5v5.js";
7
7
  import {
8
8
  FACT_CONFIDENCES,
9
+ FACT_DECIDERS,
9
10
  FACT_KINDS,
10
11
  MAX_FACTS,
11
12
  MAX_FACT_CHARS,
@@ -14,7 +15,7 @@ import {
14
15
  isLive,
15
16
  isRetired,
16
17
  noteDeprecations
17
- } from "./chunk-fm141jak.js";
18
+ } from "./chunk-p0ns6fex.js";
18
19
  import {
19
20
  SRC_PATTERNS,
20
21
  asDocument,
@@ -85,10 +86,10 @@ function validateFactsFile(input) {
85
86
  issues.push({ path: `${path}.source.q`, message: `expected ${readableSource(Q_RE)} or null` });
86
87
  }
87
88
  const decidedBy = row.source.decided_by;
88
- if (decidedBy !== undefined && decidedBy !== "owner" && decidedBy !== "driver") {
89
+ if (decidedBy !== undefined && !FACT_DECIDERS.includes(decidedBy)) {
89
90
  issues.push({
90
91
  path: `${path}.source.decided_by`,
91
- message: "expected owner, driver or absent"
92
+ message: `expected ${FACT_DECIDERS.join(", ")} or absent`
92
93
  });
93
94
  }
94
95
  } else if (row.source !== undefined) {
@@ -109,6 +110,21 @@ function validateFactsFile(input) {
109
110
  links.forEach((id2, i2) => requireString(id2, `${path}.conflicts_with[${i2}]`, issues));
110
111
  }
111
112
  }
113
+ if (row.alternatives !== undefined) {
114
+ if (requireArray(row.alternatives, `${path}.alternatives`, issues)) {
115
+ const options = row.alternatives;
116
+ if (options.length === 0) {
117
+ issues.push({
118
+ path: `${path}.alternatives`,
119
+ message: "expected at least one option not taken, or the key absent"
120
+ });
121
+ }
122
+ options.forEach((option, i2) => requireString(option, `${path}.alternatives[${i2}]`, issues));
123
+ }
124
+ }
125
+ if (row.recommended_why !== undefined) {
126
+ requireString(row.recommended_why, `${path}.recommended_why`, issues);
127
+ }
112
128
  const superseded = typeof row.superseded_by === "string";
113
129
  const retired = isRecord(row.retired) && row.retired.at !== null && row.retired.at !== undefined;
114
130
  if (superseded && retired) {
@@ -245,7 +261,9 @@ class FactsStore {
245
261
  superseded_by: null,
246
262
  retired: input.retired ?? null,
247
263
  ...cut || input.truncated === true ? { truncated: true } : {},
248
- ...input.conflicts_with !== undefined && input.conflicts_with.length > 0 ? { conflicts_with: [...input.conflicts_with] } : {}
264
+ ...input.conflicts_with !== undefined && input.conflicts_with.length > 0 ? { conflicts_with: [...input.conflicts_with] } : {},
265
+ ...input.alternatives !== undefined && input.alternatives.length > 0 ? { alternatives: [...input.alternatives] } : {},
266
+ ...input.recommended_why === undefined ? {} : { recommended_why: input.recommended_why }
249
267
  };
250
268
  this.rows.push(fact);
251
269
  return fact;