tldr-experts 0.26.0 → 0.27.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,183 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.27.0 — 2026-09-14
4
+
5
+ ### Added
6
+
7
+ - **The Plan gate holds a plan to its shape, not only to whether it can execute (#316, #317,
8
+ #318, #319).** A planning audit of 9 runs (relayed, not re-run) found 25 of 54 build-gate
9
+ rejections were dependency reopens and every multi-story plan in one workspace a
10
+ one-story-per-wave chain; a 4-wave plan left 2 stories never attempted, while the 2-wave cap
11
+ lived only as skill prose and a `seed check` advisory nothing enforced. The `plan` check now
12
+ refuses more waves than the framework carries per run unless `waves.yml` records the new
13
+ optional `wave_cap_reason` (an additive root key — `version: 1` only grows), refuses a story
14
+ scheduled later than its `depends_on` requires, and names in its detail a dod command some
15
+ stories of one epic and repo carry and a sibling does not — an advisory, because which
16
+ stories share a shape is not machine-readable and the evidence is one epic. Vertical slices,
17
+ inventory files in `touches:` and end-to-end placement are Plan-prompt rules rather than
18
+ checks, because no stack-independent name marks a route file and no changed-file set exists
19
+ at Plan time to compare against. Every rule, number and sentence comes from one file,
20
+ `src/core/plan/planShape.ts`, which the gate, the rendered "Plan shape" section
21
+ (`tldrx plan schema`) and `seed check` all read; the gate's pass is its own, not
22
+ `validatePlan`'s, so a plan approved before it still loads at Build.
23
+
24
+ - **`tldrx run auto --rebalance-finished` moves a blocked phase's shortfall out of money a
25
+ finished phase can no longer spend (#314).** MEASURED on a field run: `04-build` was refused
26
+ $11.07 short while `01-what` had finished $16.25 under its ceiling and the run total had $141
27
+ of room; `run auto` stopped and a person typed `budget raise 04-build 12 --take-from 01-what`
28
+ and relaunched. The move was already sanctioned — nothing looked for it. With the flag, the
29
+ budget gate moves EXACTLY the shortfall before refusing, out of phases whose every stage is
30
+ `done`/`skipped`, none stale, priced in `metered-usd` and with no unmetered turn (a lower-bound
31
+ spend proves nothing about what is left) — so `05-watch` with a stage still to run never gives.
32
+ It goes through `budget raise --take-from`'s own code, so the run ceiling never grows; it never
33
+ passes a recorded grant, not even under `on_grant_exceed: warn`; it never makes a partial move.
34
+ Each move is one `budget.raised` with both phases' before/after, `source` and the launcher as
35
+ actor. OPT-IN, not a default or a `budget.yml` key, because a phase ceiling is a person's
36
+ decision about money — the reason `--retry-failed` and `--until-done` never touch exit 2.
37
+ Without the flag every money refusal now names the finished phases' unspent money and the exact
38
+ `--take-from` move, and `budget.blocked` records `short_usd`, `finished_unspent_usd` and
39
+ `uncovered_usd`. An agent gate on the stage a move unblocked still falls to a person, and its
40
+ reason now names the flag, the launcher and the donor phase instead of "a person moved" it.
41
+
42
+ ### Fixed
43
+
44
+ - **`tldrx ship` no longer opens a PR over a stale or red epic (#315).** Four PRs it opened in
45
+ one audit window came back red and needed a person, because between choosing the base and
46
+ `gh pr create` nothing fetched the base or ran a workspace command. Ship now fetches the base,
47
+ merges it into the epic in a throwaway worktree when behind, and runs the `done` stories'
48
+ Definition of Done on that tree; a conflict or a red command refuses (exit 2) naming the
49
+ paths or the command. Under `ship.push` the base is merged into the epic (forward, never
50
+ rebased or forced) and pushed with it; without it the branch is untouched. `--dry-run` runs
51
+ the same checks and exits the same way.
52
+
53
+ - **A reviewer is given at least $2.00, not $1.00 — the old floor sat below the reviews that
54
+ finish (#307, owner decision: "A: piso $2").** MEASURED in the issue across 44 reviewer task
55
+ rows in two workspaces: the 37 that returned a verdict cost $0.17–$1.02 (median $0.50), so the
56
+ most expensive COMPLETED review already spent more than the $1.00 floor allowed; and a 3-story
57
+ run whose Plan priced every story at $8–$9 put every reviewer on that floor — at the shipped
58
+ defaults an $8 story derives `8 × 0.25 / (2 × 1.25)` = $0.80 — and one died with
59
+ `Reached maximum budget ($1)` before reading the diff, parking its story at `review`. $1.00
60
+ was never measured; it was chosen because it looked reasonable. `REVIEWER_FLOOR_USD` is now
61
+ $2.00, a fixed constant — no new key, event or schema. The consequence the owner accepted:
62
+ #289's refuse-before-spawn now fires when a stage has less than $2.00 left, and the refusal
63
+ already says so with the figure (`a review costs at least $2.00`). The budget brake's copy of
64
+ the floor in `budget/remainingWork.ts` is now IMPORTED from `build/caps.ts` rather than
65
+ restated, so the brake's estimate and the executor's cap read one definition (`caps.ts`
66
+ imports only two schema leaves, so the budget-gate hook stays cheap). The Build golden moves
67
+ by exactly that figure: the four reviewer `agent.spawned` lines carry `max_budget_usd: 2`.
68
+ `test/build-parallel.test.ts`'s three-story wave moves from a $9 to a $12 stage, because at $9
69
+ three $2.00 floors plus three developers sum past the ceiling the test proves the wave fits
70
+ inside. The seed-check / plan-gate warning (part 3 of #307) is not in this change.
71
+
72
+ - **A developer whose DoD goes red gets the story's next attempt, instead of blocking it on the
73
+ first miss (#313).** Reported in #313's audit of 12 run folders (relayed, not re-measured
74
+ here): 17 human `tldrx story reopen`s were a red DoD — a snapshot not regenerated, a pinned
75
+ inventory the story's own change moved, a lint rule — that the developer never got a second
76
+ look at, each one waiting on a person to read the kept output and type the reopen. MEASURED on
77
+ `70da3d1` with the fake agent: a story at `attempts: 2` whose attempt 1 left a red DoD ended
78
+ `blocked` with ONE `task.started`. Mechanism: `settleHalf` blocked on any half-A failure,
79
+ while the reviewer's `changes` verdict already requeued while `attempt < attempts` — two
80
+ failures on the merits, one retry budget honoured for only one of them. A red DoD now takes
81
+ the same bound: the attempt settles back at the status it started from (not `blocked` — it is
82
+ about to run again) with `the DoD was red on attempt 1 of 2`, keeps its worktree, and the next developer prompt carries the kept output under `## Previous
83
+ attempt` with the DoD header (#211); the last attempt blocks, saying `the DoD stayed red on 2
84
+ of 2 attempts` when both were red. Narrow on purpose — a refused developer (#271), a cap death
85
+ (#277), a REFUSED or absent-binary dod command (#165, #209), no dod commands and a reopen that
86
+ changed nothing (#308) still block on the first attempt, because a second one buys the same
87
+ outcome. The attempts a red DoD spent are counted off `events.jsonl`, not memory: review of
88
+ the first cut MEASURED three red DoDs under `attempts: 2` when a spawn fault ended an
89
+ invocation between attempts and the next process started counting from zero. No new event,
90
+ no new field; the host door (`--prepare`/`--commit`) is unchanged. `tldrx learn`'s chapter 5
91
+ now narrates — and asserts — the two red attempts before the reopen.
92
+
93
+ ## 0.26.1 — 2026-09-14
94
+
95
+ ### Changed
96
+
97
+ - **A patch is fixes again, and this release is one: 0.26.1, not 0.27.0 (owner decision).**
98
+ MEASURED with `git tag -l`: v0.19.0 through v0.26.0 are eight releases, every one a minor and
99
+ none a patch, while 0.21.0, 0.22.0, 0.23.0, 0.25.0 and 0.26.0 carry nothing but `### Fixed`.
100
+ Mechanism: `docs/RELEASING.md` said "bump minor when a command, file schema or hook changes
101
+ behaviour; patch for fixes only", and every fix changes some command's behaviour — so the
102
+ rule had no fix left to call a patch, and the version number stopped telling an upgrader
103
+ whether a release was safe to take blind. The rule now reads by capability: a *patch* is
104
+ fixes, including a fix that makes a command do what it already promised (a refusal where it
105
+ should have refused, a message that keeps what it should say); a *minor* is a new
106
+ capability — a new command, flag, file field or schema, hook or event, or a changed meaning
107
+ of an existing one. The maintain skill used to gloss the old rule beside its citation, which
108
+ made two copies to move; it now cites `docs/RELEASING.md` "What to consider" only, and
109
+ `test/maintain-skill.test.ts` refuses a skill line that restates what a patch or a minor is
110
+ (RED on the old gloss, which it quoted).
111
+
112
+ ### Fixed
113
+
114
+ - **A Build fix round whose developer lands no diff is refused, not settled `done` (#308).**
115
+ MEASURED live on a client workspace (0.24.0, two `--for-fix` reopens, one Build re-run): the
116
+ developer read the file, said it "already satisfies every acceptance criterion", spent $1.69
117
+ across two spawns and changed nothing — and the stage closed with the story `done`, the epic
118
+ head unchanged and every gap the reopen note named still in the tree. Mechanism, confirmed at
119
+ `f1c9aa8`: `commitIfDirty` hands back the OLD head sha on a clean tree, so `settleHalf`'s "no
120
+ commit to review" gate never fired, `mergeNoFf` was a no-op on an already-merged tip, and the
121
+ ledger closes a fix round on `done` alone. `workSince` — the "did the tree move since the one
122
+ the developer was handed" comparison the two refusal branches already use — was never asked on
123
+ the success path, where a fix round is precisely the case "nothing changed and the DoD is still
124
+ green" looks normal, because that tree was accepted once already. Now, for a story a PERSON put
125
+ back with a note — a fix round or a plain reopen, off the ledger — `buildHalf` asks it BEFORE
126
+ the DoD: no work, and the attempt blocks with the existing `task.done`/`blocked` vocabulary and
127
+ a reason that carries the note's first line (`NO_DIFF_MARK`, exported and pinned) — no DoD paid
128
+ for, no reviewer asked to judge an empty diff, the fix round still open because nothing landed.
129
+ A first attempt that lands nothing is deliberately NOT gated: "added nothing — identical to the
130
+ epic" is a pinned rendering decision, not this defect. Three RED tests in
131
+ `test/story-reopen.test.ts` (no-write developer on a fix round, the same bytes rewritten, a
132
+ no-write developer on a plain reopen — each settled `done` before); the neighbouring fix-round
133
+ tests now land a REAL diff, because the fake developer's default rewrote the bytes the first
134
+ attempt committed, which is #308's shape exactly. Golden byte-identical.
135
+ - **`tldrx ship` refuses an epic carrying a story the reviewer rejected (#282).** MEASURED on a
136
+ live unattended run (0.18.2, 8 stories), story S5: DoD green, `48f8bdd merge(S5)` on the epic,
137
+ THEN `check.failed check:review verdict:changes`; the requeue did the same (`7fd2468 merge(S5)`,
138
+ `changes` again) and the story settled `blocked` — the epic carried, twice, code the reviewer
139
+ rejected twice. Merging BEFORE the review is design (#166: the reviewer reads the merged diff from
140
+ the epic base) and every verdict settles with `merged: true`; the defect was downstream. `ship`'s
141
+ only story refusal was #210's "zero stories done", so with one `done` story beside S5 it pushed
142
+ the epic and opened the PR with the rejected code in its diff, while the body listed S5 under
143
+ "Not done" with no word that the diff was there — a record lying in the dangerous direction (§7).
144
+ Reproduced at `bdc43e7` with the fake agent (`GOLDEN_ROUNDS`, S2 scripted `changes` twice): S1
145
+ `done`, S2 `blocked`, every S2 `task.done` commit an ancestor of `epic/e1`, and `ship --dry-run`
146
+ exited 0 printing the `gh pr create` line. Now `ship` asks the ledger's `lastMerge` (#295 — the
147
+ last `task.done` carrying `commit` and `epic_base`, which survives a reopen precisely so "what
148
+ does the epic hold" can be asked) for every story not `done`, and a `changes` there is a
149
+ rejection that stands: exit 2 — a gate DID say no, unlike #210's 1 — naming the story, the merge
150
+ and the handoff's own reason, with #210's remedy (`tldrx story reopen <id> --note`, re-run Build,
151
+ or open the PR by hand). Not widened to a merged diff nobody judged (`n-a`/`error`) — that is #311,
152
+ filed from this change. Guards, all passing before: a `changes` a later attempt merged over and got approved
153
+ ships; a story whose diff never merged ships; a story parked at `review` under a SIGNED fix list
154
+ ships with its findings listed. Two mutations of the predicate each redden a different test.
155
+ Golden byte-identical: nothing in Build changed. The unattended path was already closed one
156
+ door earlier — the auto gate refuses to self-sign over any story not `done`
157
+ (`autoGate.ts` `UNFINISHED_STORIES`, pinned in `test/build-executor.test.ts`) — so this reaches
158
+ a PR only after a person approved the Build gate, which is exactly when the record has to say
159
+ what the diff contains.
160
+
161
+ - **A stage-failure line keeps the branch it exists to name when the worktree path is wider than
162
+ the line (#310, #293).** MEASURED 2026-09-14 under a ~115-char session scratchpad, the very place
163
+ `AGENTS.md §2` sends every agent to work: the epic-worktree refusal put the absolute path FIRST
164
+ and the two branch names after it, `oneLine` kept the first 220 chars, and the operator line —
165
+ and `run.yml`'s task row, and the `stage.failed` event — read `epic worktree /private/tmp/…/
166
+ _epic-260829-build-E1 is checked out on \`epic/someb…`: the branch cut two characters in, while
167
+ the same test passed on the same sha under a short `TMPDIR`. A refusal that loses its subject
168
+ exactly when the path is long is a refusal that names the wrong thing when it matters most.
169
+ Two halves, one implementation each: `assertWorktreeOn` now says the branches first and the
170
+ path last (`… is checked out on \`X\`, not \`Y\` — refusing … — at <path>`), and `oneLine`'s
171
+ cap keeps a line's head AND its tail, losing the middle — so what a cut takes is the middle of
172
+ a directory whose root the operator already knows. The cap itself stays at 220: each of the
173
+ three records is still one bounded line. `test/build-executor.test.ts` builds the refusal under
174
+ a path wider than the cap on purpose (the fixture roots in `os.tmpdir()`, which reads
175
+ `TMPDIR` at call time) and pins both branches, the worktree's own directory and the bound in
176
+ all three records — RED on the old order (only the path and `…` survived), and each half
177
+ reverted alone reddens it on its own assertion; the unit test on `assertWorktreeOn` pins the
178
+ order. The existing WRONG-branch test asserts the worktree's relative directory rather than
179
+ the whole absolute path, so it no longer depends on how long the box's `TMPDIR` is.
180
+
3
181
  ## 0.26.0 — 2026-09-14
4
182
 
5
183
  ### 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.27.0 | 2026-09-14 | `beta` | Five changes for unattended runs, from a planning audit and live field measurement. The `plan` check now refuses a plan with more waves than the framework carries per run (unless `waves.yml` names a `wave_cap_reason`), refuses a story scheduled before its `depends_on` allows, and flags a dod command siblings carry that one story doesn't (#316–#319). `tldrx run auto --rebalance-finished` moves a blocked phase's exact shortfall out of a finished phase's unspent ceiling before refusing, through `budget raise --take-from` itself, so the run ceiling never grows and no grant is ever assumed; every money refusal now names the unspent total and the exact command to fix it (#314). `tldrx ship` fetches and merges the base into the epic and re-runs the `done` stories' DoD before opening a PR, so a stale or red epic refuses instead of shipping a PR that comes back red (#315). A reviewer's budget floor rises from $1.00 to $2.00, matching what completed reviews actually cost, so a review no longer dies mid-diff on a floor that was never measured (#307). And a developer whose DoD goes red gets the story's next attempt instead of blocking on the first miss, with the kept output handed to the next attempt and the bound counted from `events.jsonl` so it holds across process restarts; a refused developer, a cap death, or a broken dod command still blocks immediately (#313). |
339
+ | 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. |
338
340
  | 0.26.0 | 2026-09-14 | `beta` | Two field defects from the same class — a reader of an event payload trusting a field its writer never set — plus the ceiling that killed the turn that earned it. A wide story's measured `touches_widened` no longer throws after the turn has settled: the cap knows the three lists by name and drops them whole by count, every reader derives before/after through one function, and an executor that throws after paid turns leaves their rows and money in `run.yml` and saves before it emits. The cost surfaces then agree with each other: a turn whose result was lost to that fault reads as UNMETERED, LOWER BOUND on `tldrx cost` and in the Build handoff through the door other unmeasured turns already use, a turn still in flight reads as nothing, and a late result retires the lost mark instead of counting twice. Replay reads the reason a failed stage recorded instead of printing "no reason recorded" for every one. |
339
341
  | 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. |
340
342
  | 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. |
@@ -22,7 +22,7 @@ import {
22
22
  validateRunBudget,
23
23
  wouldExceed,
24
24
  wouldExceedHostTokens
25
- } from "./chunk-bbrhs055.js";
25
+ } from "./chunk-2qkbh9b6.js";
26
26
  import {
27
27
  EventLog
28
28
  } from "./chunk-41k19q8t.js";
@@ -222,6 +222,16 @@ function hostTokenCeiling(budget, phaseId) {
222
222
  return budget.ceiling_host_tokens ?? budget.ceiling_usd;
223
223
  }
224
224
 
225
+ // src/core/build/caps.ts
226
+ var MAX_ATTEMPTS = STAGE_TUNING_DEFAULTS.attempts;
227
+ var REVIEWER_SHARE = STAGE_TUNING_DEFAULTS.reviewerShare;
228
+ var REVIEWER_FLOOR_USD = 2;
229
+ var STORY_CAP_MULTIPLIER = STAGE_TUNING_DEFAULTS.storyCapMultiplier;
230
+ var STORY_CAP_FLOOR_USD = STAGE_TUNING_DEFAULTS.storyCapFloorUsd;
231
+ function shortBy(estimate, remaining) {
232
+ return Math.max(0.01, Math.ceil((estimate - remaining) * 100) / 100);
233
+ }
234
+
225
235
  // src/core/budget/remainingWork.ts
226
236
  import { existsSync as existsSync6, readFileSync as readFileSync6 } from "node:fs";
227
237
  import { join as join6 } from "node:path";
@@ -794,6 +804,7 @@ function validateBudget(input) {
794
804
  }
795
805
 
796
806
  // src/core/plan/validatePlan.ts
807
+ var WAVES_FILE = "waves.yml";
797
808
  var PLAN_BUDGET_FILE = "budget.yml";
798
809
 
799
810
  // src/core/build/plan.ts
@@ -971,12 +982,11 @@ function looksLikeSpawnError(detail) {
971
982
  }
972
983
 
973
984
  // src/core/budget/remainingWork.ts
974
- var MAX_ATTEMPTS = STAGE_TUNING_DEFAULTS.attempts;
975
- var REVIEWER_SHARE = STAGE_TUNING_DEFAULTS.reviewerShare;
976
- var REVIEWER_FLOOR_USD = 1;
977
- var STORY_CAP_MULTIPLIER = STAGE_TUNING_DEFAULTS.storyCapMultiplier;
978
- var STORY_CAP_FLOOR_USD = STAGE_TUNING_DEFAULTS.storyCapFloorUsd;
979
- function developerAttemptDivisor(attempt, attempts = MAX_ATTEMPTS) {
985
+ var MAX_ATTEMPTS2 = STAGE_TUNING_DEFAULTS.attempts;
986
+ var REVIEWER_SHARE2 = STAGE_TUNING_DEFAULTS.reviewerShare;
987
+ var STORY_CAP_MULTIPLIER2 = STAGE_TUNING_DEFAULTS.storyCapMultiplier;
988
+ var STORY_CAP_FLOOR_USD2 = STAGE_TUNING_DEFAULTS.storyCapFloorUsd;
989
+ function developerAttemptDivisor(attempt, attempts = MAX_ATTEMPTS2) {
980
990
  return attempt <= 1 ? 1 : attempts;
981
991
  }
982
992
  function remainingWork(input) {
@@ -1028,7 +1038,7 @@ function measure(input) {
1028
1038
  const ids = new Set(all.map((story) => story.id));
1029
1039
  const prices = progress.implicit ? new Map : loadPlanPrices(join6(input.runDir, PLAN_DIR), ids).prices;
1030
1040
  const verdicts = reviewVerdictsByStory(input.runDir);
1031
- const attempts = input.attempts ?? MAX_ATTEMPTS;
1041
+ const attempts = input.attempts ?? MAX_ATTEMPTS2;
1032
1042
  const caps = new CapMath(input, prices, all.length);
1033
1043
  const stories = [];
1034
1044
  const blocked = [];
@@ -1112,9 +1122,9 @@ class CapMath {
1112
1122
  return round2(Math.min(Math.max(derived, floor), this.maxBudgetUsd));
1113
1123
  }
1114
1124
  storyCeiling(price) {
1115
- const declared = this.input.storyCapFloorUsd ?? STORY_CAP_FLOOR_USD;
1125
+ const declared = this.input.storyCapFloorUsd ?? STORY_CAP_FLOOR_USD2;
1116
1126
  const floor = this.input.stageBudgetUsd > 0 ? Math.min(declared, this.input.stageBudgetUsd) : declared;
1117
- return Math.max(price * (this.input.storyCapMultiplier ?? STORY_CAP_MULTIPLIER), floor);
1127
+ return Math.max(price * (this.input.storyCapMultiplier ?? STORY_CAP_MULTIPLIER2), floor);
1118
1128
  }
1119
1129
  priceOf(storyId) {
1120
1130
  const price = this.prices.get(storyId);
@@ -1126,10 +1136,10 @@ class CapMath {
1126
1136
  return this.input.stageBudgetUsd <= 0 ? 1 : usd / this.input.stageBudgetUsd;
1127
1137
  }
1128
1138
  attempts() {
1129
- return this.input.attempts ?? MAX_ATTEMPTS;
1139
+ return this.input.attempts ?? MAX_ATTEMPTS2;
1130
1140
  }
1131
1141
  reviewerShare() {
1132
- return this.input.reviewerShare ?? REVIEWER_SHARE;
1142
+ return this.input.reviewerShare ?? REVIEWER_SHARE2;
1133
1143
  }
1134
1144
  worstCaseShares() {
1135
1145
  return Math.max(this.storyCount, 1) * this.attempts() * (1 + this.reviewerShare());
@@ -1235,18 +1245,12 @@ function wouldExceedHostTokens(budget, phaseId, spentTokens) {
1235
1245
  };
1236
1246
  }
1237
1247
 
1238
- // src/core/build/caps.ts
1239
- var MAX_ATTEMPTS2 = STAGE_TUNING_DEFAULTS.attempts;
1240
- var REVIEWER_SHARE2 = STAGE_TUNING_DEFAULTS.reviewerShare;
1241
- var STORY_CAP_MULTIPLIER2 = STAGE_TUNING_DEFAULTS.storyCapMultiplier;
1242
- var STORY_CAP_FLOOR_USD2 = STAGE_TUNING_DEFAULTS.storyCapFloorUsd;
1243
- function shortBy(estimate, remaining) {
1244
- return Math.max(0.01, Math.ceil((estimate - remaining) * 100) / 100);
1245
- }
1248
+ // src/core/budget/rebalance.ts
1249
+ var SETTLED = new Set(["done", "skipped"]);
1246
1250
 
1247
1251
  // src/core/budget/budgetView.ts
1248
1252
  function raiseCommand(runId, phaseId, amountUsd) {
1249
1253
  return `tldrx budget raise ${phaseId} ${amountUsd.toFixed(2)} --run ${runId}`;
1250
1254
  }
1251
1255
 
1252
- export { currentActor, nowRfc3339, spentBasis, tallyOf, DEFAULT_ON_HOST_TOKENS_EXCEED, DEFAULT_ON_GRANT_EXCEED, DEFAULT_ECONOMY, economyFor, isHostTokens, validateRunBudget, asRunBudget, expertsDir, loadExperts, readExpertDomain, pathsIntersect, stackExpertNames, BUILD_PHASE2 as BUILD_PHASE, remainingWork, wouldExceed, wouldExceedHostTokens, shortBy, raiseCommand };
1256
+ export { currentActor, nowRfc3339, spentBasis, tallyOf, DEFAULT_ON_HOST_TOKENS_EXCEED, DEFAULT_ON_GRANT_EXCEED, DEFAULT_ECONOMY, economyFor, isHostTokens, validateRunBudget, asRunBudget, expertsDir, loadExperts, readExpertDomain, pathsIntersect, stackExpertNames, WAVES_FILE, BUILD_PHASE2 as BUILD_PHASE, shortBy, remainingWork, wouldExceed, wouldExceedHostTokens, raiseCommand };
@@ -3,12 +3,13 @@ import {
3
3
  DEFAULT_ECONOMY,
4
4
  DEFAULT_ON_GRANT_EXCEED,
5
5
  DEFAULT_ON_HOST_TOKENS_EXCEED,
6
+ WAVES_FILE,
6
7
  asRunBudget,
7
8
  nowRfc3339,
8
9
  spentBasis,
9
10
  tallyOf,
10
11
  validateRunBudget
11
- } from "./chunk-bbrhs055.js";
12
+ } from "./chunk-2qkbh9b6.js";
12
13
  import {
13
14
  EventLog,
14
15
  OUTCOME_NOT_RECORDED,
@@ -421,6 +422,11 @@ class RunStore {
421
422
  this.currentBudget = fn(structuredClone(this.currentBudget));
422
423
  this.budgetMutated = true;
423
424
  }
425
+ refreshCeilings() {
426
+ if (this.budgetMutated)
427
+ return;
428
+ this.currentBudget = this.ceilingsToWrite();
429
+ }
424
430
  append(event) {
425
431
  this.events.append(event);
426
432
  }
@@ -621,6 +627,33 @@ var MIN_SLICE_BYTES = 2 * 1024;
621
627
  // src/core/facilitator/contextLedger.ts
622
628
  var DEFAULT_PROMPT_MAX_BYTES = 400 * 1024;
623
629
 
630
+ // src/core/plan/planShape.ts
631
+ var MAX_WAVES_PER_RUN = 2;
632
+ var RUN_SIZE_ISSUES = "#286/#244/#280";
633
+ var WAVE_CAP_REASON_KEY = "wave_cap_reason";
634
+ var PLAN_SHAPE_RULES = [
635
+ {
636
+ issue: "#316",
637
+ text: `**At most ${String(MAX_WAVES_PER_RUN)} waves** (patch for ${RUN_SIZE_ISSUES} — the framework's measured ` + "carry today, not a design preference). A later wave waits on every earlier merge, so each extra wave is " + "a stall: a 4-wave plan left 2 stories never attempted. The `plan` check refuses more unless the root of " + `\`${WAVES_FILE}\` records \`${WAVE_CAP_REASON_KEY}: "<why these stories cannot share a wave>"\`.`
638
+ },
639
+ {
640
+ issue: "#316",
641
+ text: "**Every story runs in the EARLIEST wave its `depends_on` allows.** `depends_on: []` runs in W1; a story " + "whose dependencies all finish in W1 runs in W2. The `plan` check refuses a story held later with nothing " + "making it wait. If something does — two stories editing one file — that is a `depends_on` edge: write it."
642
+ },
643
+ {
644
+ issue: "#317",
645
+ text: "**Each story is a vertical slice.** When its own dod goes green, what it adds is reachable from a route, " + "endpoint, command or job that a user or caller actually hits — not only from its own tests. A component " + "no route renders, or a handler nothing registers, is refused at Build review as unreachable: put the wiring " + "in the same story, and the wiring's file in its `touches`. No check reads this; the review does."
646
+ },
647
+ {
648
+ issue: "#318",
649
+ text: "**Inventory files go in `touches` up front.** Grep for the tests, snapshots and generated files that " + "ENUMERATE what the story adds — route trees, guard tables, allow-lists, architecture or coverage " + "inventories, approved snapshots — and declare them. They change because the story's change forces them " + "to, not because anyone meant to edit them, and each one left out is a `touches` widening at Build."
650
+ },
651
+ {
652
+ issue: "#319",
653
+ text: "**End-to-end coverage is not the last story.** Either a test-harness story runs in W1 and later stories " + "depend on it and run against it, or each UI or feature story carries the e2e command in its own dod. A " + "final e2e story that depends on everything is the story never reached. The `plan` check names a command " + "some stories of one epic and repo carry and a sibling does not — an advisory: say in that story why not."
654
+ }
655
+ ];
656
+
624
657
  // src/core/run/runOutcome.ts
625
658
  import { join as join3 } from "node:path";
626
659
 
@@ -20,14 +20,14 @@ import {
20
20
  runSnapshot,
21
21
  statusWithOutcome,
22
22
  whatIsWaiting
23
- } from "./chunk-pwzy19er.js";
23
+ } from "./chunk-w685jj9h.js";
24
24
  import {
25
25
  expertsDir,
26
26
  loadExperts,
27
27
  pathsIntersect,
28
28
  readExpertDomain,
29
29
  stackExpertNames
30
- } from "./chunk-bbrhs055.js";
30
+ } from "./chunk-2qkbh9b6.js";
31
31
  import {
32
32
  isFinished
33
33
  } from "./chunk-41k19q8t.js";
@@ -2,8 +2,8 @@
2
2
  import {
3
3
  bar,
4
4
  runSnapshot
5
- } from "./chunk-pwzy19er.js";
6
- import"./chunk-bbrhs055.js";
5
+ } from "./chunk-w685jj9h.js";
6
+ import"./chunk-2qkbh9b6.js";
7
7
  import"./chunk-41k19q8t.js";
8
8
  import"./chunk-m1s8a6s0.js";
9
9
  import"./chunk-yre2scxn.js";