tldr-experts 0.25.0 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,169 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.26.1 — 2026-09-14
4
+
5
+ ### Changed
6
+
7
+ - **A patch is fixes again, and this release is one: 0.26.1, not 0.27.0 (owner decision).**
8
+ MEASURED with `git tag -l`: v0.19.0 through v0.26.0 are eight releases, every one a minor and
9
+ none a patch, while 0.21.0, 0.22.0, 0.23.0, 0.25.0 and 0.26.0 carry nothing but `### Fixed`.
10
+ Mechanism: `docs/RELEASING.md` said "bump minor when a command, file schema or hook changes
11
+ behaviour; patch for fixes only", and every fix changes some command's behaviour — so the
12
+ rule had no fix left to call a patch, and the version number stopped telling an upgrader
13
+ whether a release was safe to take blind. The rule now reads by capability: a *patch* is
14
+ fixes, including a fix that makes a command do what it already promised (a refusal where it
15
+ should have refused, a message that keeps what it should say); a *minor* is a new
16
+ capability — a new command, flag, file field or schema, hook or event, or a changed meaning
17
+ of an existing one. The maintain skill used to gloss the old rule beside its citation, which
18
+ made two copies to move; it now cites `docs/RELEASING.md` "What to consider" only, and
19
+ `test/maintain-skill.test.ts` refuses a skill line that restates what a patch or a minor is
20
+ (RED on the old gloss, which it quoted).
21
+
22
+ ### Fixed
23
+
24
+ - **A Build fix round whose developer lands no diff is refused, not settled `done` (#308).**
25
+ MEASURED live on a client workspace (0.24.0, two `--for-fix` reopens, one Build re-run): the
26
+ developer read the file, said it "already satisfies every acceptance criterion", spent $1.69
27
+ across two spawns and changed nothing — and the stage closed with the story `done`, the epic
28
+ head unchanged and every gap the reopen note named still in the tree. Mechanism, confirmed at
29
+ `f1c9aa8`: `commitIfDirty` hands back the OLD head sha on a clean tree, so `settleHalf`'s "no
30
+ commit to review" gate never fired, `mergeNoFf` was a no-op on an already-merged tip, and the
31
+ ledger closes a fix round on `done` alone. `workSince` — the "did the tree move since the one
32
+ the developer was handed" comparison the two refusal branches already use — was never asked on
33
+ the success path, where a fix round is precisely the case "nothing changed and the DoD is still
34
+ green" looks normal, because that tree was accepted once already. Now, for a story a PERSON put
35
+ back with a note — a fix round or a plain reopen, off the ledger — `buildHalf` asks it BEFORE
36
+ the DoD: no work, and the attempt blocks with the existing `task.done`/`blocked` vocabulary and
37
+ a reason that carries the note's first line (`NO_DIFF_MARK`, exported and pinned) — no DoD paid
38
+ for, no reviewer asked to judge an empty diff, the fix round still open because nothing landed.
39
+ A first attempt that lands nothing is deliberately NOT gated: "added nothing — identical to the
40
+ epic" is a pinned rendering decision, not this defect. Three RED tests in
41
+ `test/story-reopen.test.ts` (no-write developer on a fix round, the same bytes rewritten, a
42
+ no-write developer on a plain reopen — each settled `done` before); the neighbouring fix-round
43
+ tests now land a REAL diff, because the fake developer's default rewrote the bytes the first
44
+ attempt committed, which is #308's shape exactly. Golden byte-identical.
45
+ - **`tldrx ship` refuses an epic carrying a story the reviewer rejected (#282).** MEASURED on a
46
+ live unattended run (0.18.2, 8 stories), story S5: DoD green, `48f8bdd merge(S5)` on the epic,
47
+ THEN `check.failed check:review verdict:changes`; the requeue did the same (`7fd2468 merge(S5)`,
48
+ `changes` again) and the story settled `blocked` — the epic carried, twice, code the reviewer
49
+ rejected twice. Merging BEFORE the review is design (#166: the reviewer reads the merged diff from
50
+ the epic base) and every verdict settles with `merged: true`; the defect was downstream. `ship`'s
51
+ only story refusal was #210's "zero stories done", so with one `done` story beside S5 it pushed
52
+ the epic and opened the PR with the rejected code in its diff, while the body listed S5 under
53
+ "Not done" with no word that the diff was there — a record lying in the dangerous direction (§7).
54
+ Reproduced at `bdc43e7` with the fake agent (`GOLDEN_ROUNDS`, S2 scripted `changes` twice): S1
55
+ `done`, S2 `blocked`, every S2 `task.done` commit an ancestor of `epic/e1`, and `ship --dry-run`
56
+ exited 0 printing the `gh pr create` line. Now `ship` asks the ledger's `lastMerge` (#295 — the
57
+ last `task.done` carrying `commit` and `epic_base`, which survives a reopen precisely so "what
58
+ does the epic hold" can be asked) for every story not `done`, and a `changes` there is a
59
+ rejection that stands: exit 2 — a gate DID say no, unlike #210's 1 — naming the story, the merge
60
+ and the handoff's own reason, with #210's remedy (`tldrx story reopen <id> --note`, re-run Build,
61
+ or open the PR by hand). Not widened to a merged diff nobody judged (`n-a`/`error`) — that is #311,
62
+ filed from this change. Guards, all passing before: a `changes` a later attempt merged over and got approved
63
+ ships; a story whose diff never merged ships; a story parked at `review` under a SIGNED fix list
64
+ ships with its findings listed. Two mutations of the predicate each redden a different test.
65
+ Golden byte-identical: nothing in Build changed. The unattended path was already closed one
66
+ door earlier — the auto gate refuses to self-sign over any story not `done`
67
+ (`autoGate.ts` `UNFINISHED_STORIES`, pinned in `test/build-executor.test.ts`) — so this reaches
68
+ a PR only after a person approved the Build gate, which is exactly when the record has to say
69
+ what the diff contains.
70
+
71
+ - **A stage-failure line keeps the branch it exists to name when the worktree path is wider than
72
+ the line (#310, #293).** MEASURED 2026-09-14 under a ~115-char session scratchpad, the very place
73
+ `AGENTS.md §2` sends every agent to work: the epic-worktree refusal put the absolute path FIRST
74
+ and the two branch names after it, `oneLine` kept the first 220 chars, and the operator line —
75
+ and `run.yml`'s task row, and the `stage.failed` event — read `epic worktree /private/tmp/…/
76
+ _epic-260829-build-E1 is checked out on \`epic/someb…`: the branch cut two characters in, while
77
+ the same test passed on the same sha under a short `TMPDIR`. A refusal that loses its subject
78
+ exactly when the path is long is a refusal that names the wrong thing when it matters most.
79
+ Two halves, one implementation each: `assertWorktreeOn` now says the branches first and the
80
+ path last (`… is checked out on \`X\`, not \`Y\` — refusing … — at <path>`), and `oneLine`'s
81
+ cap keeps a line's head AND its tail, losing the middle — so what a cut takes is the middle of
82
+ a directory whose root the operator already knows. The cap itself stays at 220: each of the
83
+ three records is still one bounded line. `test/build-executor.test.ts` builds the refusal under
84
+ a path wider than the cap on purpose (the fixture roots in `os.tmpdir()`, which reads
85
+ `TMPDIR` at call time) and pins both branches, the worktree's own directory and the bound in
86
+ all three records — RED on the old order (only the path and `…` survived), and each half
87
+ reverted alone reddens it on its own assertion; the unit test on `assertWorktreeOn` pins the
88
+ order. The existing WRONG-branch test asserts the worktree's relative directory rather than
89
+ the whole absolute path, so it no longer depends on how long the box's `TMPDIR` is.
90
+
91
+ ## 0.26.0 — 2026-09-14
92
+
93
+ ### Fixed
94
+
95
+ - **`tldrx replay` says what a failed stage died of, instead of `FAILED: no reason recorded` for
96
+ every one (#309).** MEASURED by inspection at `03d53e8`: the ONLY writer of `stage.failed` in the
97
+ tree — and, per `git log -S`, in its whole history — carries the failure as `payload.reason`
98
+ (`runNext.ts`, the fail path; the same field `stage.skipped` uses, and the same field `run auto`'s
99
+ own summary reads). The narrative renderer read `payload.error`, a field nothing ever set, so
100
+ `text()` was always empty and the fallback always fired — a stakeholder reading a replay saw
101
+ that a stage failed and never why, while the reason sat one field over in the same line of
102
+ `events.jsonl`. Same class as the `message`/`detail` drift #249's round-1 review found:
103
+ payloads are untyped and nothing checks a reader's field name against its writer's. The
104
+ renderer now reads `reason`, like its sibling one line down; the `FAILED: ` marker and the
105
+ fallback sentence are exported constants, and `test/replay.test.ts` feeds a hand-built
106
+ `stage.failed` event and pins the rendered line to the reason it carried (RED on the old read:
107
+ the line was never found; the fallback case is a guard). No record changes shape — nothing
108
+ wrote `error`, so there is no old record to tolerate.
109
+
110
+ - **A wide story's measured widening no longer kills the invocation that earned it, and an executor that
111
+ throws after paid turns leaves their rows and their money in `run.yml` (#249).** MEASURED on a live 0.24.0
112
+ field run: a story with 16 declared touches changed 32 files, and the `story.touches_widened` the framework
113
+ measures at settle carried `paths`, `before` and `after` — three unbounded path lists, 5556 bytes against
114
+ the 4096-byte cap. `capPayload` knew `detail` and `outputs` by name and nothing else, so `EventLog.append`
115
+ threw; `measureSurface`'s "advisory, never throws" contract guarded the git read and not the emit, so the
116
+ throw went out through `settle` — after the story's `done` was on disk and before its `task.done` was in
117
+ the ledger — into `runNext`'s catch, which failed the stage with `tasks_recorded: false`: the developer's
118
+ and the reviewer's paid turns never reached `run.yml`, and every ceiling that derives from recorded spend
119
+ was short by exactly that, so the next economy refusal that should have fired would not have. Three fixes,
120
+ one per seam. The cap seam knows the three lists BY NAME in one declared table (`DROPPABLE_LISTS`):
121
+ `after` first (the largest, and derivable from the other two), then `before`, then `paths` last — each
122
+ dropped WHOLE and only while the payload is still over, each replaced by `<field>_omitted: N` and the same
123
+ reason-and-pointer sentence `outputs_omitted` carries, never a shortened list, which would read downstream
124
+ as the whole one; a `paths_omitted` a writer already put there (`worktree.foreign_work_aside`) is added
125
+ to, not overwritten. Every reader — `wideningRows`, the Build handoff, `tldrx replay` — renders
126
+ `(16 → 29 path(s))` from ONE derivation, `listCount`, so a row the cap could not carry reads the same
127
+ everywhere. `measureSurface` never throws: a refused emit records a bounded absence on the same event (the
128
+ counts, and a `note` naming the failure), and a log that refuses even that says so on stderr while the
129
+ story still settles. And the structural half, the expensive one: the Build executor carries the rows it
130
+ had already earned ON the error it throws (`withPartialTasks` — data on the same object, never a wrapper,
131
+ so what threw is still what the caller sees), and `runNext`'s catch records them through the same
132
+ `recordExecutorTasks` the return path uses BEFORE it fails the stage: the `error` event says
133
+ `tasks_recorded: true` with `rows_written`/`rows_expected` measured off the store after the write, the
134
+ rows keep their own `done` rather than being repainted with a throw they did not produce, and
135
+ `budget.spent_usd` is what was paid. Pinned by a real fake-agent build whose settle throws after both turns:
136
+ MEASURED on that case, the ledger before the fix recorded 0 rows / $0.00 for two paid turns, and after it
137
+ records 2 rows / $0.50. Which is the sentence an operator needs after this release: the cost half does not
138
+ just stop losing money, it stops LYING in favour of spend. With paid turns now recorded, ceilings bite
139
+ earlier — a run that used to reach the end because the ledger counted short may now be refused mid-way,
140
+ correctly but differently, so a new economy refusal after upgrading is this change working, not a
141
+ regression to hunt elsewhere. Pre-merge review found the double fault behind that: when recording the
142
+ carried rows itself threw (an `agent.result` the cap cannot rescue), rows and events were interleaved, so
143
+ a throw at event k stranded rows k+1… in memory and nothing the spend surfaces read said so — a plausible
144
+ short total, worse than a loud zero — and the `error` event was emitted BEFORE the save with a free-text
145
+ `recording_error` the cap did not trim, so a long one could throw the seam and lose the rows with no
146
+ trace. Now every row goes into the store before any event is appended (the in-memory row write cannot
147
+ throw; the append is the only line that can), the store is saved before the `error` event is emitted and
148
+ that emit is wrapped, `recording_error` sits in the cap's prose table beside `detail`, and no new
149
+ vocabulary reaches `budget show`, the dashboard or status: `run.yml` is whole, so they read a
150
+ measurement. Pre-merge review round 2 found the events side was NOT labelled — `tldrx cost --stories`
151
+ reads only `agent.result` events, so a story the fault left un-evented came back with no measured cost
152
+ and zero unmetered turns, a silent null at odds with the `budget.spent_usd` run.yml records. Now the
153
+ story ledger walks the log in order: a spawn opens a slot for its story, a result closes one (or, arriving
154
+ late from an orphaned agent after its slot was already counted lost, retires that lost slot — a result is
155
+ proof the turn was metered whenever it lands, never both the dollars and a lost turn), and an
156
+ invocation-terminal event on that stage (`stage.done`/`failed`/`skipped`, the executor or record-tasks
157
+ `error`, or a later `stage.started` that superseded it) turns every slot still open into a LOST turn,
158
+ which enters the SAME unmetered/lower-bound door: the story reads as a LOWER BOUND with the turn count,
159
+ not a confident zero (the dollars are in run.yml; a task row carries no story key, so they are named
160
+ absent per story, never invented). A spawn with nothing terminal after it is a turn IN FLIGHT — every
161
+ healthy mid-run read of `tldrx cost` and of the handoff — and is counted as nothing, review round 3's
162
+ finding: `spawned − accounted` alone had flagged every live build as a lower bound. Events carry no
163
+ attempt id, so the log's own order is the attempt scope. `tldrx replay`
164
+ renders the error line from `detail` (it read `message`, a field the event never carried) and reports
165
+ `rows_written` of `rows_expected` — reports, never sums.
166
+
3
167
  ## 0.25.0 — 2026-09-14
4
168
 
5
169
  ### Fixed
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.1 | 2026-09-14 | `beta` | Three refusals that stop a bad state from settling quietly. A Build fix round whose developer lands no diff is refused before the DoD instead of closing `done` on an unmoved tree, because `commitIfDirty` hands back the old head sha on a clean commit and the existing "no commit to review" gate never fires for it — `workSince` is now asked on the success path too, for any story a person put back with a note. `tldrx ship` refuses an epic carrying a story the reviewer marked `changes`: it asks the ledger's `lastMerge` for every story not `done` and exits 2 naming the story, the merge and the handoff's reason, instead of opening a PR whose "Not done" list hid rejected code already merged into the diff. And a stage-failure line keeps the branch it names when the worktree path is wider than the line: the refusal puts the branches before the path, and `oneLine` keeps a line's head and tail instead of dropping the identifiers that sat at the end. |
339
+ | 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. |
338
340
  | 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. |
339
341
  | 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. |
340
342
  | 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. |
@@ -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";
@@ -25,7 +25,7 @@ import {
25
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,
@@ -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
@@ -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,
@@ -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,7 +20,7 @@ import {
20
20
  runSnapshot,
21
21
  statusWithOutcome,
22
22
  whatIsWaiting
23
- } from "./chunk-rgn23z77.js";
23
+ } from "./chunk-pwzy19er.js";
24
24
  import {
25
25
  expertsDir,
26
26
  loadExperts,
@@ -30,7 +30,7 @@ import {
30
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-rgn23z77.js";
5
+ } from "./chunk-pwzy19er.js";
6
6
  import"./chunk-bbrhs055.js";
7
- import"./chunk-6z5rmj0b.js";
7
+ import"./chunk-41k19q8t.js";
8
8
  import"./chunk-m1s8a6s0.js";
9
9
  import"./chunk-yre2scxn.js";
10
10
  import"./chunk-bznt6jb2.js";
package/dist/tldrx.js CHANGED
@@ -7362,24 +7362,34 @@ function capPayload(payload, save) {
7362
7362
  const where = (text, field) => save === undefined ? "the full text was not preserved and is not recoverable from this event" : save(text, field);
7363
7363
  const named = (size, text, field) => `${String(size)} bytes exceeds the ${String(MAX_PAYLOAD_BYTES)}-byte cap — ${where(text, field)}`;
7364
7364
  let current = payload;
7365
- if (typeof current.detail === "string") {
7366
- const { detail: dropped, ...rest } = current;
7367
- current = { ...rest, detail_omitted: named(byteSize(current), dropped, "detail") };
7365
+ for (const field of DROPPABLE_PROSE) {
7366
+ const dropped = current[field];
7367
+ if (typeof dropped !== "string")
7368
+ continue;
7369
+ const { [field]: _gone, ...rest } = current;
7370
+ current = { ...rest, [`${field}_omitted`]: named(byteSize(current), dropped, field) };
7368
7371
  if (byteSize(current) <= MAX_PAYLOAD_BYTES)
7369
7372
  return current;
7370
7373
  }
7371
- if (Array.isArray(current.outputs) && current.outputs.length > 0) {
7372
- const { outputs: dropped, ...rest } = current;
7373
- const list = dropped;
7374
+ for (const field of DROPPABLE_LISTS) {
7375
+ if (byteSize(current) <= MAX_PAYLOAD_BYTES)
7376
+ return current;
7377
+ const list = current[field];
7378
+ if (!Array.isArray(list) || list.length === 0)
7379
+ continue;
7380
+ const { [field]: dropped, ...rest } = current;
7381
+ const already = current[`${field}_omitted`];
7374
7382
  current = {
7375
7383
  ...rest,
7376
- outputs_omitted: list.length,
7377
- outputs_omitted_reason: named(byteSize(current), list.map((p) => String(p)).join(`
7378
- `), "outputs")
7384
+ [`${field}_omitted`]: list.length + (typeof already === "number" ? already : 0),
7385
+ [`${field}_omitted_reason`]: named(byteSize(current), dropped.map((p) => String(p)).join(`
7386
+ `), field)
7379
7387
  };
7380
7388
  }
7381
7389
  return current;
7382
7390
  }
7391
+ var DROPPABLE_LISTS = ["outputs", "after", "before", "paths"];
7392
+ var DROPPABLE_PROSE = ["recording_error", "detail"];
7383
7393
  function byteSize(payload) {
7384
7394
  return Buffer.byteLength(JSON.stringify(payload), "utf8");
7385
7395
  }
@@ -16563,6 +16573,7 @@ var ENTRIES = [
16563
16573
  "It pushes ONLY when the run says so. Publishing a branch is a decision, so by default a branch the remote has not seen is a refusal that names the `git push` command rather than running it. The decision is taken once, at `tldrx run new --ship <push|pr|merge>` (#253): then this verb pushes the epic first, through the one push wrapper in the codebase (the Build phase itself still has none, spec §5), and under `merge` arms `gh pr merge --auto --merge` after the PR opens — unless the PR reports NO check at all, or the BASE branch was not seen to require one (#274: `--auto` waits on the base's requirements, not on the PR's reported checks, so over a base that requires nothing it merges at once), in which case the PR is left open and run.yml says which absence it was. `run auto` calls this verb when it sees the run close and skips a run whose record already carries `shipped_at`; typing `tldrx ship` again is the recovery after a partial failure — a repo whose merge is recorded `failed` is armed again, one recorded `queued` is left alone, and the record is the union, so a recorded failure is never erased. A run without the block is never written.",
16564
16574
  "The body is WRITTEN for a PR (#167): an **Outcome** line saying how much of the plan landed (`partial: 1 of 3 stories delivered; S2 — …`); what shipped and what did not, from the handoff's own done/not-done split; the reviewer findings still open, read from the run's fix lists; and the LAST phase handoff the run has on disk — `04-build/handoff.md` on a run that built something — verbatim and complete, inside a `<details>` block. It goes to `gh` as a file, never as an argument, so a long body cannot overflow an argv limit.",
16565
16575
  "It REFUSES a run that delivered no story (exit 1 — nothing behind it, #210). `ship` already knew: the body it renders keeps only the handoff bullets that say `done`, and with none it wrote `(nothing settled `done` in this run)` into a PR it opened anyway. The refusal names the counts and the first blocked story's own reason, and `--dry-run` is refused in the same words. A run that delivered at least one story ships exactly as it did before.",
16576
+ "It REFUSES an epic carrying a story the reviewer REJECTED (exit 2 — a gate said no, #282). A story merges into its epic BEFORE its review, by design, so a `changes` verdict leaves the diff on the branch: with one story `done` beside it the PR opened with the rejected code in its diff and a body that listed the story under \"Not done\" without saying so. The refusal reads the ledger's last merge for every story not `done` — the last `task.done` carrying `commit` and `epic_base` — and names the story, that merge and the handoff's own reason. A `changes` a later attempt merged over and got approved is not a rejection, a story whose diff never merged has nothing to refuse, and a story parked at `review` under a SIGNED fix list ships with its findings listed. `--dry-run` is refused in the same words.",
16566
16577
  "It moves no gate and no cursor and appends no event. On a run with no `ship:` block it never writes to the run at all; on a run with one it writes exactly that block's record (`pr_urls`, `merge`, `merges`, `shipped_at`) and nothing else. To mirror the plan's epics and stories to a ticket tool, `tldrx tickets sync` is the verb that does that, and it stays separate.",
16567
16578
  "It refuses cleanly, in a sentence, when there is no epic branch, no handoff, no remote, no `gh` on PATH, or when several epic branches leave the choice open.",
16568
16579
  "When the branch exists in SEVERAL repos — the normal shape of a chained multi-repo run, whose epics share one integration branch — it opens one PR per repo: the same body, the repo name in the title, and every URL listed at the end. `--repo` narrows it to one.",
@@ -25898,7 +25909,7 @@ async function assertWorktreeOn(path, branch, what = "worktree") {
25898
25909
  const on = await currentBranch(path);
25899
25910
  if (on === branch)
25900
25911
  return;
25901
- throw new WorktreeBranchMismatchError(`${what} ${path} is checked out on \`${on === "" ? "(no branch)" : on}\`, not \`${branch}\` — ` + "refusing to touch it, because a merge here would land on another run's branch");
25912
+ throw new WorktreeBranchMismatchError(`${what} is checked out on \`${on === "" ? "(no branch)" : on}\`, not \`${branch}\` — ` + `refusing to touch it, because a merge here would land on another run's branch — at ${path}`);
25902
25913
  }
25903
25914
  async function shaReachability(cwd, sha, ref) {
25904
25915
  const resolved = await git(["rev-parse", "--verify", "--quiet", `${sha}^{commit}`], cwd);
@@ -26562,6 +26573,11 @@ var AS_IS_MARK = "the branch was taken AS IT STANDS — no developer was spawned
26562
26573
  var AS_IS_NOT_AHEAD_MARK = "nothing to take as it stands";
26563
26574
  var AS_IS_JUDGED_MARK = "the last review of that work stands";
26564
26575
  var AS_IS_REVIEW_ONLY_MARK = "the review was run AS IT STANDS — nothing was merged and no developer was spawned";
26576
+ var NO_DIFF_MARK = "the developer produced no diff";
26577
+ function noDiffAfterReopenReason(parts) {
26578
+ const round4 = parts.fix ? "the fix round" : "the reopen";
26579
+ return `${NO_DIFF_MARK}: the tree is the one it was handed (\`${parts.handed.slice(0, 7)}\`), ` + `and ${round4} ${parts.actor} signed says \`${firstLine7(parts.note)}\`. ` + "Nothing changed, so nothing could have landed it — no DoD ran and no reviewer was spawned. " + "Reopen it with a note that names what the developer has to change";
26580
+ }
26565
26581
  function asIsNotAheadReason(branch, epicBranch, ahead, judged) {
26566
26582
  if (ahead === null) {
26567
26583
  return `${AS_IS_NOT_AHEAD_MARK}: git could not count what \`${branch}\` carries beyond ` + `\`${epicBranch}\`, and a settlement that spawns no developer will not merge on a ` + "measurement it does not have. Nothing was merged";
@@ -30038,8 +30054,9 @@ function wideningBullets(rows) {
30038
30054
  for (const row2 of rows.slice(0, MAX_WIDENING_BULLETS)) {
30039
30055
  const named = row2.paths.slice(0, NAMED_WIDENED_PATHS).map((path) => `\`${path}\``).join(", ");
30040
30056
  const more = row2.paths.length > NAMED_WIDENED_PATHS ? `, +${String(row2.paths.length - NAMED_WIDENED_PATHS)} more` : "";
30057
+ const unlisted = (row2.pathsOmitted ?? 0) > 0 ? `${row2.paths.length === 0 ? "" : ", "}${String(row2.pathsOmitted)} path(s) not listed on the event` : "";
30041
30058
  const opening = row2.basis === "measured" ? `${row2.story}'s declared surface was under the work it did (measured)` : `${row2.story}'s surface was widened by a person (declared)`;
30042
- out.push(`- ${opening}: +${named}${more} ` + `(${String(row2.before)} → ${String(row2.after)} path(s))` + `${row2.note === "" ? "" : ` — ${row2.note}`} [src: events.jsonl:${String(row2.line)}]`);
30059
+ out.push(`- ${opening}: +${named}${more}${unlisted} ` + `(${String(row2.before)} → ${String(row2.after)} path(s))` + `${row2.note === "" ? "" : ` — ${row2.note}`} [src: events.jsonl:${String(row2.line)}]`);
30043
30060
  }
30044
30061
  if (rows.length > MAX_WIDENING_BULLETS) {
30045
30062
  const next = rows[MAX_WIDENING_BULLETS]?.line ?? 1;
@@ -35798,6 +35815,11 @@ var MEASURED_NOTE_TAIL = " — measured off the story's own diff, the range the
35798
35815
  function basisOf(payload) {
35799
35816
  return payload.basis === "measured" ? "measured" : "declared";
35800
35817
  }
35818
+ function listCount(payload, field) {
35819
+ const list = payload[field];
35820
+ const omitted = payload[`${field}_omitted`];
35821
+ return (Array.isArray(list) ? list.length : 0) + (typeof omitted === "number" ? omitted : 0);
35822
+ }
35801
35823
  function stringList3(value) {
35802
35824
  return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
35803
35825
  }
@@ -35822,8 +35844,9 @@ function wideningRows(eventsText) {
35822
35844
  story: typeof payload.story === "string" ? payload.story : "?",
35823
35845
  basis: basisOf(payload),
35824
35846
  paths: stringList3(payload.paths),
35825
- before: stringList3(payload.before).length,
35826
- after: stringList3(payload.after).length,
35847
+ ...typeof payload.paths_omitted === "number" && payload.paths_omitted > 0 ? { pathsOmitted: payload.paths_omitted } : {},
35848
+ before: listCount(payload, "before"),
35849
+ after: listCount(payload, "after"),
35827
35850
  note: typeof payload.note === "string" ? payload.note : "",
35828
35851
  line: i + 1
35829
35852
  });
@@ -38830,6 +38853,9 @@ function storyLedger(runDir2, scope = null, extraTurns = []) {
38830
38853
  const ceilings = new Map;
38831
38854
  const measured = new Map;
38832
38855
  const unmetered = new Map;
38856
+ const open = new Map;
38857
+ const openStage = new Map;
38858
+ const lost = new Map;
38833
38859
  const storyKeys = new Set;
38834
38860
  const storyPhases = new Set;
38835
38861
  let excluded = 0;
@@ -38878,6 +38904,39 @@ function storyLedger(runDir2, scope = null, extraTurns = []) {
38878
38904
  else
38879
38905
  measured.set(turn.key, round9((measured.get(turn.key) ?? 0) + turn.costUsd));
38880
38906
  }
38907
+ for (const event4 of events) {
38908
+ if (event4.type === "agent.spawned" && inScope(event4)) {
38909
+ const story = str10(event4.payload.story);
38910
+ if (story === null)
38911
+ continue;
38912
+ open.set(story, (open.get(story) ?? 0) + 1);
38913
+ openStage.set(story, event4.stage);
38914
+ continue;
38915
+ }
38916
+ if (event4.type === "agent.result" && inScope(event4)) {
38917
+ const key = str10(event4.payload.key);
38918
+ if (key === null || !isStory(key, str10(event4.payload.phase)))
38919
+ continue;
38920
+ const opened = open.get(key) ?? 0;
38921
+ if (opened > 0)
38922
+ open.set(key, opened - 1);
38923
+ else if ((lost.get(key) ?? 0) > 0)
38924
+ lost.set(key, (lost.get(key) ?? 0) - 1);
38925
+ continue;
38926
+ }
38927
+ if (!closesAttempt(event4))
38928
+ continue;
38929
+ for (const [story, count] of open) {
38930
+ if (count === 0 || openStage.get(story) !== event4.stage)
38931
+ continue;
38932
+ lost.set(story, (lost.get(story) ?? 0) + count);
38933
+ open.set(story, 0);
38934
+ }
38935
+ }
38936
+ for (const [story, count] of lost) {
38937
+ if (count > 0)
38938
+ unmetered.set(story, (unmetered.get(story) ?? 0) + count);
38939
+ }
38881
38940
  return {
38882
38941
  rows: order.map((story) => ({
38883
38942
  story,
@@ -38889,6 +38948,14 @@ function storyLedger(runDir2, scope = null, extraTurns = []) {
38889
38948
  events: events.length
38890
38949
  };
38891
38950
  }
38951
+ function closesAttempt(event4) {
38952
+ if (event4.type === "stage.started" || event4.type === "stage.done" || event4.type === "stage.failed" || event4.type === "stage.skipped")
38953
+ return true;
38954
+ if (event4.type !== "error")
38955
+ return false;
38956
+ const where = str10(event4.payload.where);
38957
+ return where === "executor" || where === "record-tasks";
38958
+ }
38892
38959
  function buildStoryCost(runDir2) {
38893
38960
  let title = "";
38894
38961
  let runId = basename13(runDir2);
@@ -39006,7 +39073,7 @@ function storyVerdict(cost) {
39006
39073
  return "no total: a story above is missing one side, and an absent figure is not summed as zero.";
39007
39074
  }
39008
39075
  if (floor) {
39009
- return `no verdict: ${turns} unmetered, so at least one story's measurement is a LOWER BOUND ` + "— an unmetered turn could put it over a spawn ceiling this cannot see.";
39076
+ return `no verdict: ${turns} unmetered, so at least one story's measurement is a ${LOWER_BOUND_MARK} ` + "— an unmetered turn could put it over a spawn ceiling this cannot see.";
39010
39077
  }
39011
39078
  return "every story measured inside the spawn ceilings it was given.";
39012
39079
  }
@@ -39035,7 +39102,7 @@ function renderStoryCost(cost) {
39035
39102
  lines.push(" measured not recorded — no metered `agent.result` carried a cost for that story. " + "A turn this process never metered is a cost it did not see, never a free one.");
39036
39103
  }
39037
39104
  if (cost.unmeteredStories.length > 0) {
39038
- lines.push(` ${cost.unmeteredStories.join(", ")}: ${plural3(cost.unmeteredTurns, "turn")} UNMETERED, so ` + "that measurement is a LOWER BOUND, not a total.");
39105
+ lines.push(` ${cost.unmeteredStories.join(", ")}: ${plural3(cost.unmeteredTurns, "turn")} UNMETERED, so ` + `that measurement is a ${LOWER_BOUND_MARK}, not a total.`);
39039
39106
  }
39040
39107
  if (cost.excludedKeyedResults > 0)
39041
39108
  lines.push(` ${excludedLine(cost)}`);
@@ -39104,6 +39171,7 @@ function readBudget(runDir2) {
39104
39171
  var ECONOMY_WIDTH = 12;
39105
39172
  var DASH = "—";
39106
39173
  var NOT_RECORDED = "not recorded";
39174
+ var LOWER_BOUND_MARK = "LOWER BOUND";
39107
39175
  function tokenColumns(t, declared2 = 0) {
39108
39176
  const measured = `${bigTokens(t.input)} in · ${bigTokens(t.output)} out · ` + `${bigTokens(t.cacheCreation)} cache write · ${bigTokens(t.cacheRead)} cache read`;
39109
39177
  if (declared2 <= 0)
@@ -39287,7 +39355,7 @@ async function buildExecutor(ctx) {
39287
39355
  try {
39288
39356
  await session.restoreForeignWorkAside();
39289
39357
  } catch {}
39290
- throw error;
39358
+ throw withPartialTasks(error, session.tasks);
39291
39359
  }
39292
39360
  }
39293
39361
  function refusedOnBase(session, error) {
@@ -40024,6 +40092,26 @@ class BuildSession {
40024
40092
  before
40025
40093
  };
40026
40094
  }
40095
+ const reopen = this.reopenFor(planned);
40096
+ if (reopen !== null) {
40097
+ const proven = await workSince({
40098
+ workspaceRoot: this.workspace.root,
40099
+ repoDir: story.repoDir,
40100
+ worktree: story.worktree,
40101
+ since: handed
40102
+ });
40103
+ if (!proven) {
40104
+ return {
40105
+ story,
40106
+ cost: spent,
40107
+ dod: [],
40108
+ commit: null,
40109
+ failure: noDiffAfterReopenReason({ handed, ...reopen }),
40110
+ developerError: null,
40111
+ before
40112
+ };
40113
+ }
40114
+ }
40027
40115
  const dod = await this.runDod(story);
40028
40116
  if (!this.dodProves(dod)) {
40029
40117
  const failing = dodFailure(dod);
@@ -40393,6 +40481,14 @@ class BuildSession {
40393
40481
  return;
40394
40482
  this.lines.push(` · ${planned.story.id} was reopened by ${ledger4.reopened.actor} (${ledger4.reopened.note}) — ` + `the verdicts before that do not count against it, so it runs as attempt 1 of ${String(this.attempts)}`);
40395
40483
  }
40484
+ reopenFor(planned) {
40485
+ const ledger4 = readReviewLedger(this.ctx.runDir, planned.story.id);
40486
+ if (ledger4.fixRound !== null)
40487
+ return { note: ledger4.fixRound.note, actor: ledger4.fixRound.actor, fix: true };
40488
+ if (ledger4.reopened !== null)
40489
+ return { note: ledger4.reopened.note, actor: ledger4.reopened.actor, fix: false };
40490
+ return null;
40491
+ }
40396
40492
  waveFailed(wave) {
40397
40493
  return wave.stories.some((planned) => this.statusOf(planned) === "blocked");
40398
40494
  }
@@ -41173,14 +41269,33 @@ class BuildSession {
41173
41269
  const measured = measuredWidening(changed, declared2);
41174
41270
  if (measured === null)
41175
41271
  return;
41176
- this.ctx.emit("story.touches_widened", {
41177
- story: story.planned.story.id,
41178
- paths: [...measured.paths],
41179
- note: measured.note,
41180
- before: [...measured.before],
41181
- after: [...measured.after],
41182
- basis: "measured"
41183
- }, 0, "framework");
41272
+ const id = story.planned.story.id;
41273
+ const actor = "framework";
41274
+ try {
41275
+ this.ctx.emit("story.touches_widened", {
41276
+ story: id,
41277
+ paths: [...measured.paths],
41278
+ note: measured.note,
41279
+ before: [...measured.before],
41280
+ after: [...measured.after],
41281
+ basis: "measured"
41282
+ }, 0, actor);
41283
+ return;
41284
+ } catch (error) {
41285
+ const why = firstLine7(error instanceof Error ? error.message : String(error));
41286
+ try {
41287
+ this.ctx.emit("story.touches_widened", {
41288
+ story: id,
41289
+ paths_omitted: measured.paths.length,
41290
+ before_omitted: measured.before.length,
41291
+ after_omitted: measured.after.length,
41292
+ note: `${measured.note} — the lists could not be recorded on this event: ${why}`,
41293
+ basis: "measured"
41294
+ }, 0, actor);
41295
+ } catch (again) {
41296
+ this.advisories.push(`${id}: the measured widening of its surface (${String(measured.paths.length)} path(s) outside ` + `\`touches:\`) could not be recorded in events.jsonl — ` + firstLine7(again instanceof Error ? again.message : String(again)));
41297
+ }
41298
+ }
41184
41299
  }
41185
41300
  orderedOutcomes() {
41186
41301
  const rows = [];
@@ -41791,6 +41906,20 @@ function failed3(ctx, error, tasks) {
41791
41906
  }
41792
41907
 
41793
41908
  // src/core/facilitator/executors/index.ts
41909
+ var PARTIAL_TASKS = Symbol.for("tldrx.partialTasks");
41910
+ function withPartialTasks(error, tasks) {
41911
+ if (tasks.length === 0)
41912
+ return error;
41913
+ const carrier = typeof error === "object" && error !== null ? error : new Error(String(error));
41914
+ Object.defineProperty(carrier, PARTIAL_TASKS, { value: [...tasks], enumerable: false, configurable: true });
41915
+ return carrier;
41916
+ }
41917
+ function partialTasksOf(error) {
41918
+ if (typeof error !== "object" || error === null)
41919
+ return [];
41920
+ const tasks = error[PARTIAL_TASKS];
41921
+ return Array.isArray(tasks) ? tasks : [];
41922
+ }
41794
41923
  var EXECUTORS = new Map([
41795
41924
  [BUILD_PHASE, buildExecutor],
41796
41925
  [WATCH_PHASE, watchExecutor]
@@ -42535,14 +42664,33 @@ async function runExecutor(store, options, phaseId, stageId, spec, notes, execut
42535
42664
  outcome = await executor(executorCtx);
42536
42665
  } catch (error) {
42537
42666
  const why = error instanceof Error ? error.message : String(error);
42538
- appendCapped("error", {
42667
+ const partial = partialTasksOf(error);
42668
+ const progress2 = { rows: 0, events: 0 };
42669
+ let recordingFailed = null;
42670
+ if (partial.length > 0) {
42671
+ try {
42672
+ recordExecutorTasks(store, options, phaseId, stageId, spec, partial, progress2);
42673
+ } catch (again) {
42674
+ recordingFailed = again instanceof Error ? again.message : String(again);
42675
+ }
42676
+ }
42677
+ const written = requireStage(store, phaseId, stageId).tasks.length - tasksBefore;
42678
+ const whole = partial.length > 0 && written === partial.length;
42679
+ store.save();
42680
+ const extra = appendOrSay(() => appendCapped("error", {
42539
42681
  phase: phaseId,
42540
42682
  where: "executor",
42541
42683
  detail: why,
42542
- tasks_recorded: false
42543
- }, 0);
42544
- store.save();
42545
- return failStage(store, options, phaseId, stageId, `the executor threw before returning its task rows — this invocation's turn costs are not in run.yml: ${why}`, notes, tasksBefore);
42684
+ tasks_recorded: whole,
42685
+ rows_written: written,
42686
+ rows_expected: partial.length,
42687
+ events_written: progress2.events,
42688
+ ...recordingFailed === null ? {} : { recording_error: recordingFailed }
42689
+ }, 0));
42690
+ const n = String(partial.length);
42691
+ const eventsShort = recordingFailed === null ? "" : `; ${String(progress2.events)} of ${n} agent.result events could not be appended: ${recordingFailed}`;
42692
+ const reason = partial.length === 0 ? `the executor threw before returning its task rows — this invocation's turn costs are not in run.yml: ${why}` : whole ? `the executor threw after ${n} turn(s) — their rows and costs are in run.yml${eventsShort}: ${why}` : `the executor threw after ${n} turn(s) and recording their rows threw too — ` + `${String(written)} of ${n} rows are in run.yml and the rest are not: ${why}; recording: ${recordingFailed ?? "unknown"}`;
42693
+ return failStage(store, options, phaseId, stageId, reason, [...notes, ...extra], tasksBefore + written);
42546
42694
  }
42547
42695
  if (isSequencingRefusal(outcome)) {
42548
42696
  if (!started) {
@@ -42552,20 +42700,25 @@ async function runExecutor(store, options, phaseId, stageId, spec, notes, execut
42552
42700
  return out(EXIT_USAGE3, [...notes, ...outcome.lines], [], executorSignature(outcome));
42553
42701
  }
42554
42702
  claimEpicBranches(store, outcome.epicBranches, outcome.branchModel);
42703
+ const progress = { rows: 0, events: 0 };
42555
42704
  try {
42556
- recordExecutorTasks(store, options, phaseId, stageId, spec, outcome);
42705
+ recordExecutorTasks(store, options, phaseId, stageId, spec, outcome.tasks, progress);
42557
42706
  } catch (error) {
42558
42707
  const why = error instanceof Error ? error.message : String(error);
42559
42708
  const recorded = requireStage(store, phaseId, stageId).tasks.length;
42560
- appendCappedEvent(store, options, phaseId, stageId, "error", {
42709
+ const written = recorded - tasksBefore;
42710
+ const expected = outcome.tasks.length;
42711
+ store.save();
42712
+ const extra = appendOrSay(() => appendCappedEvent(store, options, phaseId, stageId, "error", {
42561
42713
  phase: phaseId,
42562
42714
  where: "record-tasks",
42563
42715
  detail: why,
42564
- rows_written: recorded - tasksBefore,
42565
- rows_expected: outcome.tasks.length
42566
- }, 0);
42567
- store.save();
42568
- return failStage(store, options, phaseId, stageId, `recording this invocation's task rows threw — ${String(recorded - tasksBefore)} of ` + `${String(outcome.tasks.length)} rows are in run.yml and the rest are not: ${why}`, notes, recorded);
42716
+ rows_written: written,
42717
+ rows_expected: expected,
42718
+ events_written: progress.events
42719
+ }, 0));
42720
+ const rows = written === expected ? `all ${String(expected)} rows are in run.yml and ${String(progress.events)} of ${String(expected)} ` + "agent.result events were appended" : `${String(written)} of ${String(expected)} rows are in run.yml and the rest are not`;
42721
+ return failStage(store, options, phaseId, stageId, `recording this invocation's task rows threw — ${rows}: ${why}`, [...notes, ...extra], recorded);
42569
42722
  }
42570
42723
  store.save();
42571
42724
  if (store.cancelledUnder) {
@@ -42595,11 +42748,22 @@ async function runExecutor(store, options, phaseId, stageId, spec, notes, execut
42595
42748
  function isSequencingRefusal(outcome) {
42596
42749
  return outcome.sequencing === true && outcome.refused === true && outcome.tasks.length === 0 && outcome.costUsd === 0 && (outcome.epicBranches?.length ?? 0) === 0;
42597
42750
  }
42598
- function recordExecutorTasks(store, options, phaseId, stageId, spec, outcome) {
42751
+ function appendOrSay(append) {
42752
+ try {
42753
+ append();
42754
+ return [];
42755
+ } catch (error) {
42756
+ const why = error instanceof Error ? error.message : String(error);
42757
+ return [`the error event for this failure could not be appended to events.jsonl — ${why}; run.yml is saved regardless`];
42758
+ }
42759
+ }
42760
+ function recordExecutorTasks(store, options, phaseId, stageId, spec, tasks, progress = { rows: 0, events: 0 }) {
42599
42761
  const version = frameworkVersionSync();
42600
- for (const task2 of outcome.tasks) {
42762
+ const rows = [];
42763
+ for (const task2 of tasks) {
42601
42764
  const id = nextTaskId(store, phaseId, stageId);
42602
42765
  const metered = task2.metered !== false;
42766
+ rows.push({ id, task: task2, metered });
42603
42767
  recordTask(store, phaseId, stageId, {
42604
42768
  id,
42605
42769
  status: task2.error === null ? "done" : "failed",
@@ -42618,6 +42782,9 @@ function recordExecutorTasks(store, options, phaseId, stageId, spec, outcome) {
42618
42782
  ...cacheSplit(task2.usage?.cache_creation_input_tokens, task2.usage?.cache_read_input_tokens),
42619
42783
  ...task2.durationMs === undefined ? {} : { duration_ms: task2.durationMs, duration_basis: "spawned" }
42620
42784
  });
42785
+ progress.rows += 1;
42786
+ }
42787
+ for (const { id, task: task2, metered } of rows) {
42621
42788
  appendCappedEvent(store, options, phaseId, stageId, "agent.result", {
42622
42789
  phase: phaseId,
42623
42790
  task: id,
@@ -42632,6 +42799,7 @@ function recordExecutorTasks(store, options, phaseId, stageId, spec, outcome) {
42632
42799
  ...task2.tokens === undefined ? {} : { tokens: task2.tokens },
42633
42800
  ...task2.usage === undefined ? {} : { usage: usagePayload(task2.usage) }
42634
42801
  }, metered ? round26(task2.costUsd) : 0);
42802
+ progress.events += 1;
42635
42803
  }
42636
42804
  }
42637
42805
  async function commitStage(store, options, phaseId, stageId, spec, notes) {
@@ -43397,7 +43565,10 @@ function nowish(options) {
43397
43565
  function oneLine7(text3, max = 220) {
43398
43566
  const line = text3.split(`
43399
43567
  `)[0]?.trim() ?? "";
43400
- return line.length > max ? `${line.slice(0, max - 1)}…` : line;
43568
+ if (line.length <= max)
43569
+ return line;
43570
+ const tail2 = Math.floor((max - 1) / 2);
43571
+ return `${line.slice(0, max - 1 - tail2)}…${line.slice(line.length - tail2)}`;
43401
43572
  }
43402
43573
  function phaseMoney(store, phaseId) {
43403
43574
  const phase = store.budget.phases.find((entry) => entry.id === phaseId);
@@ -43612,6 +43783,19 @@ async function shipRun(options) {
43612
43783
  };
43613
43784
  }
43614
43785
  const stories = runStories(store);
43786
+ const rejected = rejectedOnEpic(store.runDir, stories, view);
43787
+ if (rejected.length > 0) {
43788
+ const ids = rejected.map((row2) => row2.id);
43789
+ return refuse2([
43790
+ `${store.runId} carries a story the reviewer rejected, and its diff is on \`${branch}\``,
43791
+ ...rejected.map((row2) => ` ${row2.id} — its last merge into the epic (commit ${row2.commit.slice(0, 7)} over epic base ` + `${row2.epicBase.slice(0, 7)}) was judged \`changes\`${row2.reason === null ? "" : `: ${row2.reason}`}`),
43792
+ " A story merges into the epic BEFORE its review, so a `changes` verdict leaves the diff on the branch;",
43793
+ ` a PR over \`${branch}\` would carry that code, and its body would list ${ids.join(", ")} under "Not done"`,
43794
+ " without saying the diff is there.",
43795
+ ` Unblock ${ids.length === 1 ? "it" : "them"} (` + (ids.length === 1 ? `\`tldrx story reopen ${ids[0] ?? ""} --note "<why>"\`` : `\`tldrx story reopen <id> --note "<why>"\` for each`) + ") and re-run Build so a verdict that stands lands over it,",
43796
+ " or open the PR by hand if you mean to ship the branch as it stands."
43797
+ ]);
43798
+ }
43615
43799
  const body = writeShipBody(store, branch, handoff, stories, new Set(loadWorkspace(options.root).repos.keys()));
43616
43800
  try {
43617
43801
  return await shipTo(options, store, branch, body, stories);
@@ -44061,6 +44245,23 @@ function remedyPaths(refused2, excused) {
44061
44245
  return `${PROJECT_WORK_DIR} ${PROJECT_FRAMEWORK_DIR}`;
44062
44246
  return refused2.map(quote2).join(" ");
44063
44247
  }
44248
+ function rejectedOnEpic(runDir2, stories, view) {
44249
+ const rows = [];
44250
+ for (const story of stories) {
44251
+ if (story.status === "done")
44252
+ continue;
44253
+ const merge = readReviewLedger(runDir2, story.id).lastMerge;
44254
+ if (merge === null || merge.verdict !== "changes")
44255
+ continue;
44256
+ rows.push({
44257
+ id: story.id,
44258
+ commit: merge.commit,
44259
+ epicBase: merge.epicBase,
44260
+ reason: view?.unfinished.find((row2) => row2.id === story.id)?.reason ?? null
44261
+ });
44262
+ }
44263
+ return rows;
44264
+ }
44064
44265
  function settledTouches(stories) {
44065
44266
  const rows = [];
44066
44267
  for (const story of stories) {
@@ -53512,6 +53713,8 @@ import { isAbsolute as isAbsolute13, join as join135 } from "node:path";
53512
53713
  import { existsSync as existsSync111, readFileSync as readFileSync98, readdirSync as readdirSync34 } from "node:fs";
53513
53714
  import { basename as basename17, join as join131 } from "node:path";
53514
53715
  // src/core/replay/renderReplay.ts
53716
+ var STAGE_FAILED_MARKER = "FAILED: ";
53717
+ var NO_REASON_RECORDED = "no reason recorded";
53515
53718
  function renderReplay(loaded) {
53516
53719
  const run2 = loaded.run;
53517
53720
  const out2 = [
@@ -53639,9 +53842,9 @@ function bullet2(item, trail) {
53639
53842
  case "stage.done":
53640
53843
  return `${prefix5}ended`;
53641
53844
  case "stage.failed":
53642
- return `${prefix5}FAILED: ${text3(payload2.error) || "no reason recorded"}`;
53845
+ return `${prefix5}${STAGE_FAILED_MARKER}${text3(payload2.reason) || NO_REASON_RECORDED}`;
53643
53846
  case "stage.skipped":
53644
- return `${prefix5}skipped: ${text3(payload2.reason) || "no reason recorded"}`;
53847
+ return `${prefix5}skipped: ${text3(payload2.reason) || NO_REASON_RECORDED}`;
53645
53848
  case "question.asked":
53646
53849
  return `${prefix5}${q || "a question"} asked by ${actor}: ${text3(payload2.question)}`.trimEnd();
53647
53850
  case "question.answered":
@@ -53657,7 +53860,7 @@ function bullet2(item, trail) {
53657
53860
  case "story.reopened":
53658
53861
  return `${prefix5}story ${text3(payload2.story) || "?"} REOPENED by ${actor}` + ` — back to \`${text3(payload2.to_status) || "todo"}\` from \`${text3(payload2.from_status) || "?"}\`` + `${note(payload2.note)}`;
53659
53862
  case "story.touches_widened":
53660
- return `${prefix5}story ${text3(payload2.story) || "?"}'s \`touches:\` WIDENED by ${actor}` + ` (${basisOf(payload2)})` + ` — +${pathList(payload2.paths)}` + ` (${String(lengthOf(payload2.before))} → ${String(lengthOf(payload2.after))} path(s))` + `${note(payload2.note)}`;
53863
+ return `${prefix5}story ${text3(payload2.story) || "?"}'s \`touches:\` WIDENED by ${actor}` + ` (${basisOf(payload2)})` + ` — +${widenedPaths(payload2)}` + ` (${String(listCount(payload2, "before"))} → ${String(listCount(payload2, "after"))} path(s))` + `${note(payload2.note)}`;
53661
53864
  case "story.base_fastforwarded":
53662
53865
  return `${prefix5}story ${text3(payload2.story) || "?"}'s base fast-forwarded to` + ` \`${text3(payload2.base) || "?"}\` — ${text3(payload2.from) || "?"} → ${text3(payload2.to) || "?"}` + ` (${String(payload2.commits ?? "?")} commit(s))`;
53663
53866
  case "story.base_updated":
@@ -53694,8 +53897,11 @@ function bullet2(item, trail) {
53694
53897
  return `${prefix5}map refreshed`;
53695
53898
  case "result.unreadable":
53696
53899
  return `${prefix5}UNREADABLE ${text3(payload2.path) || "result.json"}` + ` — ${text3(payload2.error) || "no parse error recorded"}` + " (nothing failed; rewrite it and run the same command again)";
53697
- case "error":
53698
- return `${prefix5}error: ${text3(payload2.message) || "no message recorded"}`;
53900
+ case "error": {
53901
+ const what = text3(payload2.detail) || text3(payload2.detail_omitted) || text3(payload2.message) || "no message recorded";
53902
+ const rows = typeof payload2.rows_expected === "number" ? ` (${String(payload2.rows_written ?? "?")} of ${String(payload2.rows_expected)} task rows recorded)` : "";
53903
+ return `${prefix5}error: ${what}${rows}`;
53904
+ }
53699
53905
  default:
53700
53906
  return null;
53701
53907
  }
@@ -53753,8 +53959,12 @@ function pathList(value) {
53753
53959
  const items = value.filter((item) => typeof item === "string");
53754
53960
  return items.length === 0 ? "?" : items.join(", ");
53755
53961
  }
53756
- function lengthOf(value) {
53757
- return Array.isArray(value) ? value.length : 0;
53962
+ function widenedPaths(payload2) {
53963
+ const listed = pathList(payload2.paths);
53964
+ const omitted = typeof payload2.paths_omitted === "number" && payload2.paths_omitted > 0 ? `${String(payload2.paths_omitted)} path(s) not listed on the event` : "";
53965
+ if (omitted === "")
53966
+ return listed;
53967
+ return listed === "?" ? omitted : `${listed}, ${omitted}`;
53758
53968
  }
53759
53969
  function signature(actor, payload2) {
53760
53970
  const executed = payload2.executed_by;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tldr-experts",
3
- "version": "0.25.0",
3
+ "version": "0.26.1",
4
4
  "description": "tldr-experts: an evidence-first, file-based AI development framework - five stages, a gate on every one, and every claim cited or refused. Installs the `tldrx` (and `tldr-experts`) command. Beta.",
5
5
  "license": "MIT",
6
6
  "author": "Alan Martinez",
@@ -2,7 +2,7 @@
2
2
  "$doc": "Shape verified from https://code.claude.com/docs/en/plugins.md (Quickstart > Create the plugin manifest). Fields used here: name, description, version, author.name. Only plugin.json goes inside .claude-plugin/; skills/, agents/ and hooks/ live at the plugin root.",
3
3
  "name": "tldrx",
4
4
  "description": "tldr-experts: an evidence-first, file-based AI development framework. Five stages, a gate on every one, every claim cited or refused. Beta.",
5
- "version": "0.25.0",
5
+ "version": "0.26.1",
6
6
  "author": {
7
7
  "name": "Alan Martinez"
8
8
  }