tldr-experts 0.24.0 → 0.26.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,227 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.26.0 — 2026-09-14
4
+
5
+ ### Fixed
6
+
7
+ - **`tldrx replay` says what a failed stage died of, instead of `FAILED: no reason recorded` for
8
+ every one (#309).** MEASURED by inspection at `03d53e8`: the ONLY writer of `stage.failed` in the
9
+ tree — and, per `git log -S`, in its whole history — carries the failure as `payload.reason`
10
+ (`runNext.ts`, the fail path; the same field `stage.skipped` uses, and the same field `run auto`'s
11
+ own summary reads). The narrative renderer read `payload.error`, a field nothing ever set, so
12
+ `text()` was always empty and the fallback always fired — a stakeholder reading a replay saw
13
+ that a stage failed and never why, while the reason sat one field over in the same line of
14
+ `events.jsonl`. Same class as the `message`/`detail` drift #249's round-1 review found:
15
+ payloads are untyped and nothing checks a reader's field name against its writer's. The
16
+ renderer now reads `reason`, like its sibling one line down; the `FAILED: ` marker and the
17
+ fallback sentence are exported constants, and `test/replay.test.ts` feeds a hand-built
18
+ `stage.failed` event and pins the rendered line to the reason it carried (RED on the old read:
19
+ the line was never found; the fallback case is a guard). No record changes shape — nothing
20
+ wrote `error`, so there is no old record to tolerate.
21
+
22
+ - **A wide story's measured widening no longer kills the invocation that earned it, and an executor that
23
+ throws after paid turns leaves their rows and their money in `run.yml` (#249).** MEASURED on a live 0.24.0
24
+ field run: a story with 16 declared touches changed 32 files, and the `story.touches_widened` the framework
25
+ measures at settle carried `paths`, `before` and `after` — three unbounded path lists, 5556 bytes against
26
+ the 4096-byte cap. `capPayload` knew `detail` and `outputs` by name and nothing else, so `EventLog.append`
27
+ threw; `measureSurface`'s "advisory, never throws" contract guarded the git read and not the emit, so the
28
+ throw went out through `settle` — after the story's `done` was on disk and before its `task.done` was in
29
+ the ledger — into `runNext`'s catch, which failed the stage with `tasks_recorded: false`: the developer's
30
+ and the reviewer's paid turns never reached `run.yml`, and every ceiling that derives from recorded spend
31
+ was short by exactly that, so the next economy refusal that should have fired would not have. Three fixes,
32
+ one per seam. The cap seam knows the three lists BY NAME in one declared table (`DROPPABLE_LISTS`):
33
+ `after` first (the largest, and derivable from the other two), then `before`, then `paths` last — each
34
+ dropped WHOLE and only while the payload is still over, each replaced by `<field>_omitted: N` and the same
35
+ reason-and-pointer sentence `outputs_omitted` carries, never a shortened list, which would read downstream
36
+ as the whole one; a `paths_omitted` a writer already put there (`worktree.foreign_work_aside`) is added
37
+ to, not overwritten. Every reader — `wideningRows`, the Build handoff, `tldrx replay` — renders
38
+ `(16 → 29 path(s))` from ONE derivation, `listCount`, so a row the cap could not carry reads the same
39
+ everywhere. `measureSurface` never throws: a refused emit records a bounded absence on the same event (the
40
+ counts, and a `note` naming the failure), and a log that refuses even that says so on stderr while the
41
+ story still settles. And the structural half, the expensive one: the Build executor carries the rows it
42
+ had already earned ON the error it throws (`withPartialTasks` — data on the same object, never a wrapper,
43
+ so what threw is still what the caller sees), and `runNext`'s catch records them through the same
44
+ `recordExecutorTasks` the return path uses BEFORE it fails the stage: the `error` event says
45
+ `tasks_recorded: true` with `rows_written`/`rows_expected` measured off the store after the write, the
46
+ rows keep their own `done` rather than being repainted with a throw they did not produce, and
47
+ `budget.spent_usd` is what was paid. Pinned by a real fake-agent build whose settle throws after both turns:
48
+ MEASURED on that case, the ledger before the fix recorded 0 rows / $0.00 for two paid turns, and after it
49
+ records 2 rows / $0.50. Which is the sentence an operator needs after this release: the cost half does not
50
+ just stop losing money, it stops LYING in favour of spend. With paid turns now recorded, ceilings bite
51
+ earlier — a run that used to reach the end because the ledger counted short may now be refused mid-way,
52
+ correctly but differently, so a new economy refusal after upgrading is this change working, not a
53
+ regression to hunt elsewhere. Pre-merge review found the double fault behind that: when recording the
54
+ carried rows itself threw (an `agent.result` the cap cannot rescue), rows and events were interleaved, so
55
+ a throw at event k stranded rows k+1… in memory and nothing the spend surfaces read said so — a plausible
56
+ short total, worse than a loud zero — and the `error` event was emitted BEFORE the save with a free-text
57
+ `recording_error` the cap did not trim, so a long one could throw the seam and lose the rows with no
58
+ trace. Now every row goes into the store before any event is appended (the in-memory row write cannot
59
+ throw; the append is the only line that can), the store is saved before the `error` event is emitted and
60
+ that emit is wrapped, `recording_error` sits in the cap's prose table beside `detail`, and no new
61
+ vocabulary reaches `budget show`, the dashboard or status: `run.yml` is whole, so they read a
62
+ measurement. Pre-merge review round 2 found the events side was NOT labelled — `tldrx cost --stories`
63
+ reads only `agent.result` events, so a story the fault left un-evented came back with no measured cost
64
+ and zero unmetered turns, a silent null at odds with the `budget.spent_usd` run.yml records. Now the
65
+ story ledger walks the log in order: a spawn opens a slot for its story, a result closes one (or, arriving
66
+ late from an orphaned agent after its slot was already counted lost, retires that lost slot — a result is
67
+ proof the turn was metered whenever it lands, never both the dollars and a lost turn), and an
68
+ invocation-terminal event on that stage (`stage.done`/`failed`/`skipped`, the executor or record-tasks
69
+ `error`, or a later `stage.started` that superseded it) turns every slot still open into a LOST turn,
70
+ which enters the SAME unmetered/lower-bound door: the story reads as a LOWER BOUND with the turn count,
71
+ not a confident zero (the dollars are in run.yml; a task row carries no story key, so they are named
72
+ absent per story, never invented). A spawn with nothing terminal after it is a turn IN FLIGHT — every
73
+ healthy mid-run read of `tldrx cost` and of the handoff — and is counted as nothing, review round 3's
74
+ finding: `spawned − accounted` alone had flagged every live build as a lower bound. Events carry no
75
+ attempt id, so the log's own order is the attempt scope. `tldrx replay`
76
+ renders the error line from `detail` (it read `message`, a field the event never carried) and reports
77
+ `rows_written` of `rows_expected` — reports, never sums.
78
+
79
+ ## 0.25.0 — 2026-09-14
80
+
81
+ ### Fixed
82
+
83
+ - **A Watch card refused for an unsourced `## Where` item comes back to its writer, marked, instead
84
+ of being rewritten blind (#301).** MEASURED on two unattended field runs the same night: the
85
+ `05-watch` writer named a PostgreSQL table under `## Where` with no `[src: …]` at all — twice per
86
+ run, on both workspaces, once on two cards in one run — while the migration defining that table
87
+ was cited two lines above under `## Signal`. Three things let it through. The rule said "every
88
+ item is sourced" and listed five source kinds, and nowhere said which kind a table, queue or
89
+ dashboard takes (`stage.md`, the feature brief and the template each showed a file or a fact,
90
+ never a place). The refusal, `no [src: …] token — every item on a card is sourced`, restated the
91
+ rule and named no cure. And the retry re-ran `tldrx next` from scratch: `featurePrompt` took no
92
+ previous-card parameter, the refusal was written to the operator's terminal and never into the
93
+ next prompt, and attempt 2 on run A moved the failure from L21+L22 to L19 — an independent
94
+ re-generation, not a repair. A person hand-edited the card and recorded the stage twice. Now:
95
+ ONE sentence, `NON_FILE_SOURCE_CURE`, says what a place cites (the migration, model or config
96
+ that defines it, or the fact that names it), and the writer's brief teaches it with one worked
97
+ example while the validator's refusal repeats it verbatim — one derivation, so a retry armed with
98
+ the refusal is armed with the cure. And the retry's prompt carries `## Previous attempt` (the
99
+ same heading, H3 and 32 KB cap as the one-agent path, now constants in `prompt.ts`): the card on
100
+ disk re-read by the parser that refused it, each refused line as `L<n> <section>: <message>`
101
+ with the line quoted as written, and the card inlined under "edit, do not restart". Re-validated
102
+ at prompt time rather than replayed from `run.yml`, so a card a person has since fixed shows no
103
+ stale marks; a first attempt has no card and no section, so its prompt is unchanged. The model
104
+ pin stays `haiku`: nothing measured says the model cannot follow the rule — it was never told
105
+ the rule for this case, and the retry was never shown its own card. Pinned by the fake-agent
106
+ harness: the first attempt's refusal names line, rule and cure; the headless retry's and the
107
+ `--prepare` bundle's prompts carry the marked card; a first attempt carries no section.
108
+
109
+ - **The freeze between a release and a merge wave runs both ways now (#304).** #299 made one
110
+ direction mechanical — the wave waits on `.RELEASE-IN-PROGRESS` — and left the other as prose:
111
+ MEASURED by reading `scripts/release.sh` at `a341b4f`, it sourced `merge-lock.sh` only to name
112
+ its own marker, and nothing between its first line and `git commit` read `merge-wave.lock`. A
113
+ release started mid-wave edited CHANGELOG, README and package.json in a tree another process
114
+ was gating, and the only thing that stopped the commit was the ref hook — which aborts the
115
+ COMMIT and leaves the three edits dirty in the shared checkout, with nothing to roll them back.
116
+ `release.sh` now WAITS on a running wave's lock before it writes its marker and before its
117
+ first edit — same `MW_LOCK_*` knobs, same dead-owner rule (a dead wave's lock is broken open
118
+ and said so on stderr) — and gives up with **exit 14**, having edited nothing. The precedence
119
+ is fixed rather than symmetric (peer review of this change): a wave holding the lock finishes,
120
+ never preempted; once the marker is up the release is ahead, a wave that took the lock in the
121
+ gap hands it back as it already did, and the release waits for the lock to clear WITHOUT
122
+ handing back its marker — two sides yielding on the same poll cadence would ping-pong for the
123
+ whole budget and end in 13 and 14; pinned with the gap interleaving built deterministically (a
124
+ stand-in `mv` plants the lock the instant the marker lands). Which makes the wave's dead-owner
125
+ check the only thing that ever clears a marker a SIGKILL or a cut session orphaned (the traps
126
+ cover INT/TERM), so it now says so on stderr, naming the marker and the pid, and that sentence
127
+ is pinned. And a kept marker means two things — queued and untouched, or editing and tagging —
128
+ so it carries `phase: waiting|releasing` (rewritten atomically), which `--status` and the wave's
129
+ refusal print — and while a wave holds the lock, `--status` names the queued release on a
130
+ second line rather than answering "a wave, nothing else": a record never says more than the
131
+ truth (§7). The code: 14 rather than 1 because there is nothing to undo, and
132
+ not the wave's own 6 because the two scripts' codes are read in the same logs, so the next
133
+ number after the wave's 13 keeps a bare "exit 14" unambiguous. Pinned in both directions with
134
+ the real scripts: a real wave against a real running release exits 13, a real release against a
135
+ live wave lock exits 14 with the tree untouched, and a queued release edits nothing until the
136
+ lock is gone. The wave's exit-12 heading gate now also requires the unreleased version to be
137
+ above `package.json`'s — the CHANGELOG used to be checked only against its own top dated
138
+ heading, so a dated section never written, or a heading edited by hand, passed on two lies from
139
+ one file; `package.json` is what shipped (§9's drift guard already pins README's top row to it).
140
+ Same exit 12: one condition, measured twice, and the refusal names both figures. Two Minors the
141
+ #299 review named are pinned rather than fixed, because reading was right: `--status` on an
142
+ old-format lock (owner and token only) with a live pid prints `?` for what it never recorded,
143
+ and `ver_gt` orders `1.0.0 > 0.99.0` and `0.10.0 > 0.9.0` numerically — the function is
144
+ lifted from the script's own text for the test, so the pin is on the real implementation.
145
+
146
+ - **`tldrx next --prepare` and `--commit` ask the same dependency frontier the headless loop asks (#300).**
147
+ MEASURED at `31ed3d7` on the mixed shape #280 opened: a headless pass met S2 behind S1 at `review` and
148
+ — correctly — left S2 `todo`; the host's `--commit --review` blocked S1; the next `--prepare` said
149
+ `prepared S2 · … ($3.20 ceiling, attempt 1 of 1)` and `--commit` then merged S2 into an epic branch
150
+ S1 had put nothing on. `runAll` has asked `blockingDependency` per story since #260/#263, but the
151
+ three in-session doors took `nextPending()` / `inProgress()` as offered, and `pendingStories` skips
152
+ only `done` and a terminal `blocked` — so the `blocked` row #280 stopped writing was, incidentally,
153
+ the only thing keeping `--prepare` off that dependent. A pure in-session run had the hole all along
154
+ (S1 blocked by a verdict, S2 `todo`); the mixed run is what made it fire without a person's hand.
155
+ Now `--prepare` walks the pending stories through the ONE frontier and does per story what the loop
156
+ does — a terminal hold records the dependent `blocked` with the recorded sentence, so the reason
157
+ reaches the gate and `staleDependencyHold` releases it when the dependency lands; a pending hold
158
+ leaves the row `todo` (never `blocked`, which would re-create #280 on the other door) and, with
159
+ nothing else to offer, refuses with exit 1 saying there is nothing to prepare YET and what releases
160
+ it. `--prepare --review` refuses over a hold and records nothing. `--commit` on a story whose
161
+ dependency is no longer `done` refuses with exit 1 in both cases and writes nothing — the bundle,
162
+ the branch and the worktree stay, the story stays `in_progress`, and the sentence names all three
163
+ — because that story has a developer's attempt on its branch and the loop's row would say it had
164
+ none. Exit 1 is the sequencing family (`refusedOnSequence`): the cycle is fine and the fix is the
165
+ other command; a `2` would send the stage back to `ready` and throw away the bundle a held
166
+ `--commit` is sitting on. And the closing hint of a `--commit` with more to do asks the same frontier,
167
+ recording nothing: it used to read raw `nextPending()` and say `S2 is next — run tldrx next --prepare`
168
+ the instant S1 was blocked, naming the one bundle the next `--prepare` refuses to write; it now names
169
+ what `--prepare` would actually offer, or says nothing is next and why. One correction to the issue's
170
+ proposed sequence, measured: S1 at `review`
171
+ followed by `--prepare` does NOT hand S2 out — S1 is pending too and earlier in wave order, so that
172
+ `--prepare` offers S1's review; the dependent is offered the moment S1 leaves `review` for a status
173
+ `pendingStories` skips. Minor by behaviour: a `--prepare` and a `--commit` that used to hand out and
174
+ settle now record `blocked` or refuse.
175
+ - **A `budget raise --stage` typed during a live `run auto` loop no longer evaporates on the loop's
176
+ next save (#305).** MEASURED in the field on 0.21.0: the command printed `04-build/build budget_usd
177
+ $12.60 → $62.60`, exited 0, and two hours later `run.yml` read `budget_usd: 12.60` again with the
178
+ gate note holding the stage on `budget=$35.78 of $12.60` — the developers had been capped on the
179
+ figure the operator had raised. Not a `budget` bug: a LOST UPDATE on `run.yml`. `RunStore.save()`
180
+ re-read `budget.yml`'s ceilings from disk before writing (the 2026-08-29 fix) and wrote `run.yml`
181
+ WHOLE from the copy the store loaded — and `run auto` holds one store for the whole of a stage, the
182
+ Build fan-out included. Pinned RED through the real writers on today's `main`: the raise reverted
183
+ (`Expected: 54 / Received: 4`), a `reject --and-continue` beside a held store reverted (`rejected` →
184
+ `pending`), a `run cancel` beside one lost its `cancelled:` block. `save()` now re-reads `run.yml`
185
+ under the workspace lock and carries over it ONLY the fields this store changed since it last read
186
+ or wrote the file; every derived figure is rolled up from the merged document. That is ownership by
187
+ change, not the blind reload-and-merge the issue warned against: every value on disk was written by
188
+ the store that changed it, and a store never writes back a value it merely loaded — so the loop,
189
+ which never touches a stage's `budget_usd`, cannot revert a raise, and the raise, which never touches
190
+ task rows, cannot revert the ledger. Who changes what is now written down in spec §2.2 — the loop
191
+ owns the execution record, a person's commands own `budget_usd`, `cancelled`, gate decisions and the
192
+ policies — and the one field two writers can both change, a stage's `status` under `run cancel
193
+ --force` while a loop still runs it, has a DECLARED winner (peer review): `cancelled` is terminal
194
+ and wins. A cancel that landed under a held store keeps its statuses and the cursor on the file —
195
+ the held store's task rows still land, those turns happened and cost money — the save answers
196
+ `cancelledUnder: true` and says so on stderr, and the PROCESS obeys the file, not only the file:
197
+ the Build executor asks `RunStore.cancelledOnDisk` before every spawn (each story attempt, each
198
+ parallel lane, the reviewer) and `tldrx next` stops after the save that merged the cancel, with
199
+ the same exit 0 its "is cancelled — nothing to advance" path uses, rather than parking a
200
+ cancelled stage on a gate. Pinned through the real dispatch with the fake agent: a forced cancel
201
+ fired from S1's own DoD command leaves exactly one `agent.spawned` on the log — S1's developer —
202
+ S1 parked at `review` with an `n-a` verdict naming the cancel, S2 never started, `run.yml`
203
+ `cancelled` with the stage `cancelled`. MEASURED before the rule: the same collision spawned five
204
+ times over an already-cancelled run (S1's reviewer, S2's developer and reviewer, S1's developer
205
+ again), and the file ended `cancelled` with the stage `awaiting_gate` — #305's shape one level
206
+ down. A verdict read across two owners (the loop's spend against an
207
+ operator's ceiling) already records the figures it read beside itself — `budget.blocked` carries
208
+ `remaining_usd` and `ceiling_usd`, the auto-gate note carries `budget=$x of $y` — and that is now
209
+ the stated rule, because the file can legitimately hold a pair no single writer saw together. For
210
+ the record: `reject` had no live exposure by construction (it refuses anything but
211
+ `awaiting_gate`/`failed`, by which point the loop's store for that stage is gone) and `run cancel`
212
+ already refused under a live `.lock` unless forced; both are pinned at store level anyway, because
213
+ the next writer will not be one of these three.
214
+ Two edges from the pre-merge review, because a fallback that guesses silently is the same lie in a
215
+ smaller font: a `run.yml` on disk that exists but cannot be merged with (does not parse, does not
216
+ validate) has the in-memory copy written whole — the only honest move — but that is exactly the
217
+ case an external write may just have been destroyed, so `save()` says so on stderr, naming the
218
+ reason and the `.bak` beside the file where the replaced version is (the convention the repair path
219
+ already uses; no new event kind, none exists for repairs and §7 does not invent one); an absent file
220
+ is merged with nothing and stays silent, there was nothing to lose. And a `run.yml` that records
221
+ ANOTHER run is not damage but a violated premise: the save REFUSES, naming both ids and the path,
222
+ and writes nothing — unreachable by path construction today, pinned so it stays unreachable by
223
+ accident.
224
+
3
225
  ## 0.24.0 — 2026-09-14
4
226
 
5
227
  ### Added
package/README.md CHANGED
@@ -335,6 +335,8 @@ back on the registry is 0.3.0.
335
335
 
336
336
  | Version | Date | Status | Contains |
337
337
  |---|---|---|---|
338
+ | 0.26.0 | 2026-09-14 | `beta` | Two field defects from the same class — a reader of an event payload trusting a field its writer never set — plus the ceiling that killed the turn that earned it. A wide story's measured `touches_widened` no longer throws after the turn has settled: the cap knows the three lists by name and drops them whole by count, every reader derives before/after through one function, and an executor that throws after paid turns leaves their rows and money in `run.yml` and saves before it emits. The cost surfaces then agree with each other: a turn whose result was lost to that fault reads as UNMETERED, LOWER BOUND on `tldrx cost` and in the Build handoff through the door other unmeasured turns already use, a turn still in flight reads as nothing, and a late result retires the lost mark instead of counting twice. Replay reads the reason a failed stage recorded instead of printing "no reason recorded" for every one. |
339
+ | 0.25.0 | 2026-09-14 | `beta` | The other half of the two-session protocol and four holes measured on live runs. `scripts/release.sh` now waits on a running wave's lock before touching the tree and never hands back the marker it wrote — release has precedence, the wave yields, exit 14 when it gives up; the heading gate also refuses an unreleased version at or below `package.json`; the marker carries its phase and `--status` reports a release queued behind a wave (#304). In-session `tldrx next --prepare` and `--commit` ask the same dependency frontier the headless loop asks: a terminal hold records the dependent `blocked`, a pending hold refuses without writing, a held `--commit` keeps the work it sits on and says where it is (#300). A Watch card refused for an unsourced `## Where` item comes back to its writer marked, the refusal names the cure, and the brief shows one non-file example, so a retry edits the card instead of regenerating it blind (#301). And `run.yml` no longer loses an external write to a live loop: a save carries only the fields the store changed over a fresh read, ownership is declared, `cancelled` is terminal and wins in the file and in the process, a file that cannot be merged is kept beside as `.bak` and said so, and a file recording another run is refused (#305). Minor release: a new exit code on a public script, a stricter merge gate, and `--prepare`/`--commit` refuse where they used to proceed. |
338
340
  | 0.24.0 | 2026-09-14 | `beta` | Five fixes from one unattended night, all measured on live runs. The two-session protocol is mechanical where it was chat: the merge wave refuses a merged tree with two unreleased CHANGELOG headings or one at or below the last release (exit 12), `release.sh` holds a `.RELEASE-IN-PROGRESS` marker the wave waits on (exit 13 when it gives up), `merge-wave.sh --status` reads holder, branch and phase in one line, and the `maintain` skill gains a worker-mode section (#299). A per-story cap says what it was derived from — plan price × stage scale — and names the lever that moves it, and a plan whose prices exceed the stage budget is told so instead of being scaled in silence (#281). A story whose dependency is still at `review` waits in `todo` instead of being written `blocked`, and a dependency block left by an earlier version is released once the dependency is `done` (#280). `--until-done` compares the refusal across attempts, not the advice line printed under it, so a failure that made progress keeps its relaunches (#297). And the Build handoff no longer fails its own `claim-sources` check over a refused command that spans lines: one physical line per element, the break shown as `⏎` and explained once in the document (#283). Minor release: the wave and `--until-done` behave differently in situations that exist today. |
339
341
  | 0.23.0 | 2026-09-14 | `beta` | `tldrx story reopen --as-is` now settles a story whose work is already merged into the epic but whose review never completed (`n-a` or `error` on its last recorded merge): a new, named review-only case that merges nothing and only routes the story to its reviewer, read from an additive ledger field; the existing refusal ("no commit the epic has not got") is untouched and now names the standing verdict when one exists (`changes`, `approve`, `fixlist` still refuse). A fix list whose findings are all deferred settles the story `done` instead of spawning a developer with nothing to fix, measured on a live run that paid for four such developers (#295). And a turn that died on a provider limit no longer records `success` borrowed from the provider's own subtype: the failure record says what the host saw (#296, first half — DETECTING the provider limit as its own non-execution kind is #298 and is NOT in this release). Minor release: `story reopen --as-is` behaves differently in a situation that exists today. |
340
342
  | 0.22.0 | 2026-09-14 | `beta` | A developer may now READ its own tree — `git status`, `log`, `diff` and `show` join the one constant the grant, the developer prompt and the refusal classifier all read; measured on a live unattended run where a developer was refused `git -C <worktree> log` twice and the story died, ~$5 for a command that changes nothing. `-C <path>` (and `--git-dir`, `--work-tree`) stays ungranted as a decision with its own refusal kind, `elsewhere`, because it points git at trees the story does not own. And every refusal cure now says WHY it is a cure: three consecutive developers on one story re-appended `; echo "EXIT:$?"` to a DoD command because the cure said what to drop and never that the facilitator re-runs the Definition of Done itself and records each exit code (#287, #294). Minor release: the developer's git allowance grew and a new refusal kind was added. |
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  conflictOf
4
- } from "./chunk-tjx8g39f.js";
4
+ } from "./chunk-d0jhr5wh.js";
5
5
  import {
6
6
  FactsStore,
7
7
  formatJaccard
@@ -13,7 +13,7 @@ import {
13
13
  import {
14
14
  EventLog,
15
15
  PHASE_ID_RE
16
- } from "./chunk-6z5rmj0b.js";
16
+ } from "./chunk-41k19q8t.js";
17
17
  import {
18
18
  PHASE_IDS
19
19
  } from "./chunk-m1s8a6s0.js";
@@ -22,10 +22,10 @@ import {
22
22
  validateRunBudget,
23
23
  wouldExceed,
24
24
  wouldExceedHostTokens
25
- } from "./chunk-2jb272zj.js";
25
+ } from "./chunk-bbrhs055.js";
26
26
  import {
27
27
  EventLog
28
- } from "./chunk-6z5rmj0b.js";
28
+ } from "./chunk-41k19q8t.js";
29
29
  import {
30
30
  cursorStage,
31
31
  hostTokensIn,
@@ -706,6 +706,9 @@ function stackExpertNames(root, repos) {
706
706
  return names;
707
707
  }
708
708
 
709
+ // src/core/facilitator/prompt.ts
710
+ var MAX_PREVIOUS_ATTEMPT_BYTES = 32 * 1024;
711
+
709
712
  // src/core/text/srcGrammarContract.ts
710
713
  var KINDS = {
711
714
  file: { shape: "`[repo:]path:line[-line]`", example: "api:src/Selector.ts:241" },
@@ -5,7 +5,7 @@ import {
5
5
  evidencePath,
6
6
  gateEvidencePath,
7
7
  parseEvidence
8
- } from "./chunk-6z5rmj0b.js";
8
+ } from "./chunk-41k19q8t.js";
9
9
  import {
10
10
  openBlocks,
11
11
  parseQuestions
@@ -8,7 +8,7 @@ import {
8
8
  spentBasis,
9
9
  tallyOf,
10
10
  validateRunBudget
11
- } from "./chunk-2jb272zj.js";
11
+ } from "./chunk-bbrhs055.js";
12
12
  import {
13
13
  EventLog,
14
14
  OUTCOME_NOT_RECORDED,
@@ -21,7 +21,7 @@ import {
21
21
  isTerminal,
22
22
  stageAt,
23
23
  validateRunFile
24
- } from "./chunk-6z5rmj0b.js";
24
+ } from "./chunk-41k19q8t.js";
25
25
  import {
26
26
  cursorStage,
27
27
  isAttendedByHostView,
@@ -56,6 +56,7 @@ import { join as join4 } from "node:path";
56
56
 
57
57
  // src/core/run/RunStore.ts
58
58
  import { existsSync, readFileSync as readFileSync2 } from "node:fs";
59
+ import { isDeepStrictEqual } from "node:util";
59
60
  import { basename, join as join2 } from "node:path";
60
61
 
61
62
  // src/core/frameworkVersion.ts
@@ -297,11 +298,14 @@ class RunStore {
297
298
  events;
298
299
  current;
299
300
  currentBudget;
301
+ loaded;
302
+ cancelledUnderFlag = false;
300
303
  budgetMutated = false;
301
304
  constructor(runDir, run, budget, events) {
302
305
  this.runDir = runDir;
303
306
  this.events = events;
304
307
  this.current = run;
308
+ this.loaded = run;
305
309
  this.currentBudget = budget;
306
310
  }
307
311
  static open(runDir) {
@@ -386,6 +390,20 @@ class RunStore {
386
390
  get runId() {
387
391
  return this.current.run;
388
392
  }
393
+ get cancelledUnder() {
394
+ return this.cancelledUnderFlag;
395
+ }
396
+ static cancelledOnDisk(runDir) {
397
+ const path = join2(runDir, "run.yml");
398
+ if (!existsSync(path))
399
+ return false;
400
+ try {
401
+ const doc = parseYaml(readFileSync2(path, "utf8"));
402
+ return validateRunFile(doc).ok && asRunFile(doc).cancelled !== undefined;
403
+ } catch {
404
+ return false;
405
+ }
406
+ }
389
407
  cursorEntry() {
390
408
  return stageAt(this.current, this.current.cursor);
391
409
  }
@@ -407,13 +425,14 @@ class RunStore {
407
425
  this.events.append(event);
408
426
  }
409
427
  save() {
410
- const rolled = rollUp(this.current);
411
- const runValidation = validateRunFile(rolled);
412
- if (!runValidation.ok) {
413
- const first = runValidation.issues[0];
414
- throw new RunStoreError(`refusing to write an invalid run.yml: ${first?.path ?? ""} ${first?.message ?? ""}`);
415
- }
416
- withWorkspaceLock(workspaceRootOfRunDir(this.runDir), () => {
428
+ return withWorkspaceLock(workspaceRootOfRunDir(this.runDir), () => {
429
+ const base = this.runToWrite();
430
+ const rolled = rollUp(base.run);
431
+ const runValidation = validateRunFile(rolled);
432
+ if (!runValidation.ok) {
433
+ const first = runValidation.issues[0];
434
+ throw new RunStoreError(`refusing to write an invalid run.yml: ${first?.path ?? ""} ${first?.message ?? ""}`);
435
+ }
417
436
  const budget = rollUpBudget(this.ceilingsToWrite(), rolled);
418
437
  const budgetValidation = validateRunBudget(budget);
419
438
  if (!budgetValidation.ok) {
@@ -421,12 +440,56 @@ class RunStore {
421
440
  throw new RunStoreError(`refusing to write an invalid budget.yml: ${first?.path ?? ""} ${first?.message ?? ""}`);
422
441
  }
423
442
  writeAtomic(join2(this.runDir, "budget.yml"), emitBudgetYaml(budget));
424
- writeAtomic(join2(this.runDir, "run.yml"), emitRunYaml(rolled));
443
+ const runPath = join2(this.runDir, "run.yml");
444
+ writeAtomic(runPath, emitRunYaml(rolled));
425
445
  this.current = rolled;
446
+ this.loaded = rolled;
426
447
  this.currentBudget = budget;
427
448
  this.budgetMutated = false;
449
+ if (base.fallback !== null) {
450
+ process.stderr.write(`tldrx: ${runPath} ${base.fallback}, so it could not be merged with: this process's copy of ` + `the run was written whole, and the version that was on disk is beside it as ${backupPathFor(runPath)}. ` + "A change another command wrote there since this process read the run (a budget raise, a " + `cancel, a rejection) may be in that backup and not in run.yml.
451
+ `);
452
+ }
453
+ if (base.cancelledUnder) {
454
+ this.cancelledUnderFlag = true;
455
+ const who = rolled.cancelled;
456
+ process.stderr.write(`tldrx: run ${rolled.run} was cancelled` + (who === undefined ? "" : ` by ${who.by} at ${who.at} (${who.note})`) + " while this process held it: the cancel's statuses were kept, this process's task rows were " + `recorded beside them, and nothing more may be spawned for it.
457
+ `);
458
+ }
459
+ return { cancelledUnder: base.cancelledUnder };
428
460
  });
429
461
  }
462
+ runToWrite() {
463
+ const path = join2(this.runDir, "run.yml");
464
+ if (!existsSync(path))
465
+ return { run: this.current, fallback: null, cancelledUnder: false };
466
+ let doc;
467
+ try {
468
+ doc = parseYaml(readFileSync2(path, "utf8"));
469
+ } catch (error) {
470
+ const detail = error instanceof Error ? error.message : String(error);
471
+ return { run: this.current, fallback: `could not be read or parsed (${detail})`, cancelledUnder: false };
472
+ }
473
+ const validation = validateRunFile(doc);
474
+ if (!validation.ok) {
475
+ const first = validation.issues[0];
476
+ return {
477
+ run: this.current,
478
+ fallback: `does not validate (${first?.path ?? ""} ${first?.message ?? "schema error"})`,
479
+ cancelledUnder: false
480
+ };
481
+ }
482
+ const onDisk = asRunFile(doc);
483
+ if (onDisk.run !== this.current.run) {
484
+ throw new RunStoreError(`refusing to write ${path}: it records run ${onDisk.run}, and this store holds run ` + `${this.current.run} — a save never overwrites another run's record`);
485
+ }
486
+ const cancelledUnder = onDisk.cancelled !== undefined && this.loaded.cancelled === undefined && this.current.cancelled === undefined;
487
+ return {
488
+ run: carryChanges(onDisk, this.current, this.loaded, { cancelledWins: this.cancelledUnderFlag || cancelledUnder }),
489
+ fallback: null,
490
+ cancelledUnder
491
+ };
492
+ }
430
493
  ceilingsToWrite() {
431
494
  if (this.budgetMutated)
432
495
  return this.currentBudget;
@@ -474,6 +537,41 @@ function parseStateFile(path) {
474
537
  `));
475
538
  }
476
539
  }
540
+ function carryChanges(onDisk, mine, loaded, rules = { cancelledWins: false }) {
541
+ const top = carryLevel(onDisk, mine, loaded, rules.cancelledWins ? ["phases", "cursor"] : ["phases"]);
542
+ const phases = mine.phases.map((phase) => {
543
+ const theirs = onDisk.phases.find((p) => p.id === phase.id);
544
+ const was = loaded.phases.find((p) => p.id === phase.id);
545
+ if (theirs === undefined || was === undefined)
546
+ return phase;
547
+ const stages = phase.stages.map((stage2) => {
548
+ const theirStage = theirs.stages.find((s) => s.id === stage2.id);
549
+ const wasStage = was.stages.find((s) => s.id === stage2.id);
550
+ if (theirStage === undefined || wasStage === undefined)
551
+ return stage2;
552
+ const kept = rules.cancelledWins && theirStage.status === "cancelled" ? ["status", "started_at", "ended_at"] : [];
553
+ return carryLevel(theirStage, stage2, wasStage, kept);
554
+ });
555
+ return { ...carryLevel(theirs, phase, was, ["stages"]), stages };
556
+ });
557
+ return { ...top, phases };
558
+ }
559
+ function carryLevel(onDisk, mine, loaded, skip) {
560
+ const out = { ...onDisk };
561
+ const theirs = mine;
562
+ const was = loaded;
563
+ for (const key of new Set([...Object.keys(theirs), ...Object.keys(was)])) {
564
+ if (skip.includes(key))
565
+ continue;
566
+ if (isDeepStrictEqual(theirs[key], was[key]))
567
+ continue;
568
+ if (theirs[key] === undefined)
569
+ delete out[key];
570
+ else
571
+ out[key] = theirs[key];
572
+ }
573
+ return out;
574
+ }
477
575
  function rollUp(run) {
478
576
  const phases = run.phases.map((phase) => {
479
577
  const stages = phase.stages.map((stage2) => ({
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  questionsCard
4
- } from "./chunk-tjx8g39f.js";
4
+ } from "./chunk-d0jhr5wh.js";
5
5
  import"./chunk-jpp137mb.js";
6
6
  import {
7
7
  allow,
@@ -20,17 +20,17 @@ import {
20
20
  runSnapshot,
21
21
  statusWithOutcome,
22
22
  whatIsWaiting
23
- } from "./chunk-4frmcwp3.js";
23
+ } from "./chunk-pwzy19er.js";
24
24
  import {
25
25
  expertsDir,
26
26
  loadExperts,
27
27
  pathsIntersect,
28
28
  readExpertDomain,
29
29
  stackExpertNames
30
- } from "./chunk-2jb272zj.js";
30
+ } from "./chunk-bbrhs055.js";
31
31
  import {
32
32
  isFinished
33
- } from "./chunk-6z5rmj0b.js";
33
+ } from "./chunk-41k19q8t.js";
34
34
  import"./chunk-m1s8a6s0.js";
35
35
  import {
36
36
  openRunViews
@@ -2,9 +2,9 @@
2
2
  import {
3
3
  bar,
4
4
  runSnapshot
5
- } from "./chunk-4frmcwp3.js";
6
- import"./chunk-2jb272zj.js";
7
- import"./chunk-6z5rmj0b.js";
5
+ } from "./chunk-pwzy19er.js";
6
+ import"./chunk-bbrhs055.js";
7
+ import"./chunk-41k19q8t.js";
8
8
  import"./chunk-m1s8a6s0.js";
9
9
  import"./chunk-yre2scxn.js";
10
10
  import"./chunk-bznt6jb2.js";