tldr-experts 0.25.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,81 @@
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
+
3
79
  ## 0.25.0 — 2026-09-14
4
80
 
5
81
  ### Fixed
package/README.md CHANGED
@@ -335,6 +335,7 @@ 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. |
338
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. |
339
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. |
340
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. |
@@ -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
  }
@@ -30038,8 +30048,9 @@ function wideningBullets(rows) {
30038
30048
  for (const row2 of rows.slice(0, MAX_WIDENING_BULLETS)) {
30039
30049
  const named = row2.paths.slice(0, NAMED_WIDENED_PATHS).map((path) => `\`${path}\``).join(", ");
30040
30050
  const more = row2.paths.length > NAMED_WIDENED_PATHS ? `, +${String(row2.paths.length - NAMED_WIDENED_PATHS)} more` : "";
30051
+ const unlisted = (row2.pathsOmitted ?? 0) > 0 ? `${row2.paths.length === 0 ? "" : ", "}${String(row2.pathsOmitted)} path(s) not listed on the event` : "";
30041
30052
  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)}]`);
30053
+ 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
30054
  }
30044
30055
  if (rows.length > MAX_WIDENING_BULLETS) {
30045
30056
  const next = rows[MAX_WIDENING_BULLETS]?.line ?? 1;
@@ -35798,6 +35809,11 @@ var MEASURED_NOTE_TAIL = " — measured off the story's own diff, the range the
35798
35809
  function basisOf(payload) {
35799
35810
  return payload.basis === "measured" ? "measured" : "declared";
35800
35811
  }
35812
+ function listCount(payload, field) {
35813
+ const list = payload[field];
35814
+ const omitted = payload[`${field}_omitted`];
35815
+ return (Array.isArray(list) ? list.length : 0) + (typeof omitted === "number" ? omitted : 0);
35816
+ }
35801
35817
  function stringList3(value) {
35802
35818
  return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
35803
35819
  }
@@ -35822,8 +35838,9 @@ function wideningRows(eventsText) {
35822
35838
  story: typeof payload.story === "string" ? payload.story : "?",
35823
35839
  basis: basisOf(payload),
35824
35840
  paths: stringList3(payload.paths),
35825
- before: stringList3(payload.before).length,
35826
- after: stringList3(payload.after).length,
35841
+ ...typeof payload.paths_omitted === "number" && payload.paths_omitted > 0 ? { pathsOmitted: payload.paths_omitted } : {},
35842
+ before: listCount(payload, "before"),
35843
+ after: listCount(payload, "after"),
35827
35844
  note: typeof payload.note === "string" ? payload.note : "",
35828
35845
  line: i + 1
35829
35846
  });
@@ -38830,6 +38847,9 @@ function storyLedger(runDir2, scope = null, extraTurns = []) {
38830
38847
  const ceilings = new Map;
38831
38848
  const measured = new Map;
38832
38849
  const unmetered = new Map;
38850
+ const open = new Map;
38851
+ const openStage = new Map;
38852
+ const lost = new Map;
38833
38853
  const storyKeys = new Set;
38834
38854
  const storyPhases = new Set;
38835
38855
  let excluded = 0;
@@ -38878,6 +38898,39 @@ function storyLedger(runDir2, scope = null, extraTurns = []) {
38878
38898
  else
38879
38899
  measured.set(turn.key, round9((measured.get(turn.key) ?? 0) + turn.costUsd));
38880
38900
  }
38901
+ for (const event4 of events) {
38902
+ if (event4.type === "agent.spawned" && inScope(event4)) {
38903
+ const story = str10(event4.payload.story);
38904
+ if (story === null)
38905
+ continue;
38906
+ open.set(story, (open.get(story) ?? 0) + 1);
38907
+ openStage.set(story, event4.stage);
38908
+ continue;
38909
+ }
38910
+ if (event4.type === "agent.result" && inScope(event4)) {
38911
+ const key = str10(event4.payload.key);
38912
+ if (key === null || !isStory(key, str10(event4.payload.phase)))
38913
+ continue;
38914
+ const opened = open.get(key) ?? 0;
38915
+ if (opened > 0)
38916
+ open.set(key, opened - 1);
38917
+ else if ((lost.get(key) ?? 0) > 0)
38918
+ lost.set(key, (lost.get(key) ?? 0) - 1);
38919
+ continue;
38920
+ }
38921
+ if (!closesAttempt(event4))
38922
+ continue;
38923
+ for (const [story, count] of open) {
38924
+ if (count === 0 || openStage.get(story) !== event4.stage)
38925
+ continue;
38926
+ lost.set(story, (lost.get(story) ?? 0) + count);
38927
+ open.set(story, 0);
38928
+ }
38929
+ }
38930
+ for (const [story, count] of lost) {
38931
+ if (count > 0)
38932
+ unmetered.set(story, (unmetered.get(story) ?? 0) + count);
38933
+ }
38881
38934
  return {
38882
38935
  rows: order.map((story) => ({
38883
38936
  story,
@@ -38889,6 +38942,14 @@ function storyLedger(runDir2, scope = null, extraTurns = []) {
38889
38942
  events: events.length
38890
38943
  };
38891
38944
  }
38945
+ function closesAttempt(event4) {
38946
+ if (event4.type === "stage.started" || event4.type === "stage.done" || event4.type === "stage.failed" || event4.type === "stage.skipped")
38947
+ return true;
38948
+ if (event4.type !== "error")
38949
+ return false;
38950
+ const where = str10(event4.payload.where);
38951
+ return where === "executor" || where === "record-tasks";
38952
+ }
38892
38953
  function buildStoryCost(runDir2) {
38893
38954
  let title = "";
38894
38955
  let runId = basename13(runDir2);
@@ -39006,7 +39067,7 @@ function storyVerdict(cost) {
39006
39067
  return "no total: a story above is missing one side, and an absent figure is not summed as zero.";
39007
39068
  }
39008
39069
  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.";
39070
+ 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
39071
  }
39011
39072
  return "every story measured inside the spawn ceilings it was given.";
39012
39073
  }
@@ -39035,7 +39096,7 @@ function renderStoryCost(cost) {
39035
39096
  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
39097
  }
39037
39098
  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.");
39099
+ lines.push(` ${cost.unmeteredStories.join(", ")}: ${plural3(cost.unmeteredTurns, "turn")} UNMETERED, so ` + `that measurement is a ${LOWER_BOUND_MARK}, not a total.`);
39039
39100
  }
39040
39101
  if (cost.excludedKeyedResults > 0)
39041
39102
  lines.push(` ${excludedLine(cost)}`);
@@ -39104,6 +39165,7 @@ function readBudget(runDir2) {
39104
39165
  var ECONOMY_WIDTH = 12;
39105
39166
  var DASH = "—";
39106
39167
  var NOT_RECORDED = "not recorded";
39168
+ var LOWER_BOUND_MARK = "LOWER BOUND";
39107
39169
  function tokenColumns(t, declared2 = 0) {
39108
39170
  const measured = `${bigTokens(t.input)} in · ${bigTokens(t.output)} out · ` + `${bigTokens(t.cacheCreation)} cache write · ${bigTokens(t.cacheRead)} cache read`;
39109
39171
  if (declared2 <= 0)
@@ -39287,7 +39349,7 @@ async function buildExecutor(ctx) {
39287
39349
  try {
39288
39350
  await session.restoreForeignWorkAside();
39289
39351
  } catch {}
39290
- throw error;
39352
+ throw withPartialTasks(error, session.tasks);
39291
39353
  }
39292
39354
  }
39293
39355
  function refusedOnBase(session, error) {
@@ -41173,14 +41235,33 @@ class BuildSession {
41173
41235
  const measured = measuredWidening(changed, declared2);
41174
41236
  if (measured === null)
41175
41237
  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");
41238
+ const id = story.planned.story.id;
41239
+ const actor = "framework";
41240
+ try {
41241
+ this.ctx.emit("story.touches_widened", {
41242
+ story: id,
41243
+ paths: [...measured.paths],
41244
+ note: measured.note,
41245
+ before: [...measured.before],
41246
+ after: [...measured.after],
41247
+ basis: "measured"
41248
+ }, 0, actor);
41249
+ return;
41250
+ } catch (error) {
41251
+ const why = firstLine7(error instanceof Error ? error.message : String(error));
41252
+ try {
41253
+ this.ctx.emit("story.touches_widened", {
41254
+ story: id,
41255
+ paths_omitted: measured.paths.length,
41256
+ before_omitted: measured.before.length,
41257
+ after_omitted: measured.after.length,
41258
+ note: `${measured.note} — the lists could not be recorded on this event: ${why}`,
41259
+ basis: "measured"
41260
+ }, 0, actor);
41261
+ } catch (again) {
41262
+ 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)));
41263
+ }
41264
+ }
41184
41265
  }
41185
41266
  orderedOutcomes() {
41186
41267
  const rows = [];
@@ -41791,6 +41872,20 @@ function failed3(ctx, error, tasks) {
41791
41872
  }
41792
41873
 
41793
41874
  // src/core/facilitator/executors/index.ts
41875
+ var PARTIAL_TASKS = Symbol.for("tldrx.partialTasks");
41876
+ function withPartialTasks(error, tasks) {
41877
+ if (tasks.length === 0)
41878
+ return error;
41879
+ const carrier = typeof error === "object" && error !== null ? error : new Error(String(error));
41880
+ Object.defineProperty(carrier, PARTIAL_TASKS, { value: [...tasks], enumerable: false, configurable: true });
41881
+ return carrier;
41882
+ }
41883
+ function partialTasksOf(error) {
41884
+ if (typeof error !== "object" || error === null)
41885
+ return [];
41886
+ const tasks = error[PARTIAL_TASKS];
41887
+ return Array.isArray(tasks) ? tasks : [];
41888
+ }
41794
41889
  var EXECUTORS = new Map([
41795
41890
  [BUILD_PHASE, buildExecutor],
41796
41891
  [WATCH_PHASE, watchExecutor]
@@ -42535,14 +42630,33 @@ async function runExecutor(store, options, phaseId, stageId, spec, notes, execut
42535
42630
  outcome = await executor(executorCtx);
42536
42631
  } catch (error) {
42537
42632
  const why = error instanceof Error ? error.message : String(error);
42538
- appendCapped("error", {
42633
+ const partial = partialTasksOf(error);
42634
+ const progress2 = { rows: 0, events: 0 };
42635
+ let recordingFailed = null;
42636
+ if (partial.length > 0) {
42637
+ try {
42638
+ recordExecutorTasks(store, options, phaseId, stageId, spec, partial, progress2);
42639
+ } catch (again) {
42640
+ recordingFailed = again instanceof Error ? again.message : String(again);
42641
+ }
42642
+ }
42643
+ const written = requireStage(store, phaseId, stageId).tasks.length - tasksBefore;
42644
+ const whole = partial.length > 0 && written === partial.length;
42645
+ store.save();
42646
+ const extra = appendOrSay(() => appendCapped("error", {
42539
42647
  phase: phaseId,
42540
42648
  where: "executor",
42541
42649
  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);
42650
+ tasks_recorded: whole,
42651
+ rows_written: written,
42652
+ rows_expected: partial.length,
42653
+ events_written: progress2.events,
42654
+ ...recordingFailed === null ? {} : { recording_error: recordingFailed }
42655
+ }, 0));
42656
+ const n = String(partial.length);
42657
+ const eventsShort = recordingFailed === null ? "" : `; ${String(progress2.events)} of ${n} agent.result events could not be appended: ${recordingFailed}`;
42658
+ 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"}`;
42659
+ return failStage(store, options, phaseId, stageId, reason, [...notes, ...extra], tasksBefore + written);
42546
42660
  }
42547
42661
  if (isSequencingRefusal(outcome)) {
42548
42662
  if (!started) {
@@ -42552,20 +42666,25 @@ async function runExecutor(store, options, phaseId, stageId, spec, notes, execut
42552
42666
  return out(EXIT_USAGE3, [...notes, ...outcome.lines], [], executorSignature(outcome));
42553
42667
  }
42554
42668
  claimEpicBranches(store, outcome.epicBranches, outcome.branchModel);
42669
+ const progress = { rows: 0, events: 0 };
42555
42670
  try {
42556
- recordExecutorTasks(store, options, phaseId, stageId, spec, outcome);
42671
+ recordExecutorTasks(store, options, phaseId, stageId, spec, outcome.tasks, progress);
42557
42672
  } catch (error) {
42558
42673
  const why = error instanceof Error ? error.message : String(error);
42559
42674
  const recorded = requireStage(store, phaseId, stageId).tasks.length;
42560
- appendCappedEvent(store, options, phaseId, stageId, "error", {
42675
+ const written = recorded - tasksBefore;
42676
+ const expected = outcome.tasks.length;
42677
+ store.save();
42678
+ const extra = appendOrSay(() => appendCappedEvent(store, options, phaseId, stageId, "error", {
42561
42679
  phase: phaseId,
42562
42680
  where: "record-tasks",
42563
42681
  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);
42682
+ rows_written: written,
42683
+ rows_expected: expected,
42684
+ events_written: progress.events
42685
+ }, 0));
42686
+ 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`;
42687
+ return failStage(store, options, phaseId, stageId, `recording this invocation's task rows threw — ${rows}: ${why}`, [...notes, ...extra], recorded);
42569
42688
  }
42570
42689
  store.save();
42571
42690
  if (store.cancelledUnder) {
@@ -42595,11 +42714,22 @@ async function runExecutor(store, options, phaseId, stageId, spec, notes, execut
42595
42714
  function isSequencingRefusal(outcome) {
42596
42715
  return outcome.sequencing === true && outcome.refused === true && outcome.tasks.length === 0 && outcome.costUsd === 0 && (outcome.epicBranches?.length ?? 0) === 0;
42597
42716
  }
42598
- function recordExecutorTasks(store, options, phaseId, stageId, spec, outcome) {
42717
+ function appendOrSay(append) {
42718
+ try {
42719
+ append();
42720
+ return [];
42721
+ } catch (error) {
42722
+ const why = error instanceof Error ? error.message : String(error);
42723
+ return [`the error event for this failure could not be appended to events.jsonl — ${why}; run.yml is saved regardless`];
42724
+ }
42725
+ }
42726
+ function recordExecutorTasks(store, options, phaseId, stageId, spec, tasks, progress = { rows: 0, events: 0 }) {
42599
42727
  const version = frameworkVersionSync();
42600
- for (const task2 of outcome.tasks) {
42728
+ const rows = [];
42729
+ for (const task2 of tasks) {
42601
42730
  const id = nextTaskId(store, phaseId, stageId);
42602
42731
  const metered = task2.metered !== false;
42732
+ rows.push({ id, task: task2, metered });
42603
42733
  recordTask(store, phaseId, stageId, {
42604
42734
  id,
42605
42735
  status: task2.error === null ? "done" : "failed",
@@ -42618,6 +42748,9 @@ function recordExecutorTasks(store, options, phaseId, stageId, spec, outcome) {
42618
42748
  ...cacheSplit(task2.usage?.cache_creation_input_tokens, task2.usage?.cache_read_input_tokens),
42619
42749
  ...task2.durationMs === undefined ? {} : { duration_ms: task2.durationMs, duration_basis: "spawned" }
42620
42750
  });
42751
+ progress.rows += 1;
42752
+ }
42753
+ for (const { id, task: task2, metered } of rows) {
42621
42754
  appendCappedEvent(store, options, phaseId, stageId, "agent.result", {
42622
42755
  phase: phaseId,
42623
42756
  task: id,
@@ -42632,6 +42765,7 @@ function recordExecutorTasks(store, options, phaseId, stageId, spec, outcome) {
42632
42765
  ...task2.tokens === undefined ? {} : { tokens: task2.tokens },
42633
42766
  ...task2.usage === undefined ? {} : { usage: usagePayload(task2.usage) }
42634
42767
  }, metered ? round26(task2.costUsd) : 0);
42768
+ progress.events += 1;
42635
42769
  }
42636
42770
  }
42637
42771
  async function commitStage(store, options, phaseId, stageId, spec, notes) {
@@ -53512,6 +53646,8 @@ import { isAbsolute as isAbsolute13, join as join135 } from "node:path";
53512
53646
  import { existsSync as existsSync111, readFileSync as readFileSync98, readdirSync as readdirSync34 } from "node:fs";
53513
53647
  import { basename as basename17, join as join131 } from "node:path";
53514
53648
  // src/core/replay/renderReplay.ts
53649
+ var STAGE_FAILED_MARKER = "FAILED: ";
53650
+ var NO_REASON_RECORDED = "no reason recorded";
53515
53651
  function renderReplay(loaded) {
53516
53652
  const run2 = loaded.run;
53517
53653
  const out2 = [
@@ -53639,9 +53775,9 @@ function bullet2(item, trail) {
53639
53775
  case "stage.done":
53640
53776
  return `${prefix5}ended`;
53641
53777
  case "stage.failed":
53642
- return `${prefix5}FAILED: ${text3(payload2.error) || "no reason recorded"}`;
53778
+ return `${prefix5}${STAGE_FAILED_MARKER}${text3(payload2.reason) || NO_REASON_RECORDED}`;
53643
53779
  case "stage.skipped":
53644
- return `${prefix5}skipped: ${text3(payload2.reason) || "no reason recorded"}`;
53780
+ return `${prefix5}skipped: ${text3(payload2.reason) || NO_REASON_RECORDED}`;
53645
53781
  case "question.asked":
53646
53782
  return `${prefix5}${q || "a question"} asked by ${actor}: ${text3(payload2.question)}`.trimEnd();
53647
53783
  case "question.answered":
@@ -53657,7 +53793,7 @@ function bullet2(item, trail) {
53657
53793
  case "story.reopened":
53658
53794
  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
53795
  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)}`;
53796
+ 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
53797
  case "story.base_fastforwarded":
53662
53798
  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
53799
  case "story.base_updated":
@@ -53694,8 +53830,11 @@ function bullet2(item, trail) {
53694
53830
  return `${prefix5}map refreshed`;
53695
53831
  case "result.unreadable":
53696
53832
  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"}`;
53833
+ case "error": {
53834
+ const what = text3(payload2.detail) || text3(payload2.detail_omitted) || text3(payload2.message) || "no message recorded";
53835
+ const rows = typeof payload2.rows_expected === "number" ? ` (${String(payload2.rows_written ?? "?")} of ${String(payload2.rows_expected)} task rows recorded)` : "";
53836
+ return `${prefix5}error: ${what}${rows}`;
53837
+ }
53699
53838
  default:
53700
53839
  return null;
53701
53840
  }
@@ -53753,8 +53892,12 @@ function pathList(value) {
53753
53892
  const items = value.filter((item) => typeof item === "string");
53754
53893
  return items.length === 0 ? "?" : items.join(", ");
53755
53894
  }
53756
- function lengthOf(value) {
53757
- return Array.isArray(value) ? value.length : 0;
53895
+ function widenedPaths(payload2) {
53896
+ const listed = pathList(payload2.paths);
53897
+ const omitted = typeof payload2.paths_omitted === "number" && payload2.paths_omitted > 0 ? `${String(payload2.paths_omitted)} path(s) not listed on the event` : "";
53898
+ if (omitted === "")
53899
+ return listed;
53900
+ return listed === "?" ? omitted : `${listed}, ${omitted}`;
53758
53901
  }
53759
53902
  function signature(actor, payload2) {
53760
53903
  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.0",
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.0",
6
6
  "author": {
7
7
  "name": "Alan Martinez"
8
8
  }