tldr-experts 0.18.1 → 0.18.2

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,139 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.2 — 2026-09-13
4
+
5
+ ### Added
6
+
7
+ - **The zero-touch recipe is written down, on the pages a person opens to run one (#276).**
8
+ Measured on origin/main at 0.18.1, after the first fully unattended run had ended in a merged
9
+ pull request: `--questions`, `--ship`, `--until-done`, `--gates none` and `run cancel` were
10
+ each documented, and no page chained them into the thing that had just worked — seed file →
11
+ `run new … --gates none --questions none --ship pr --budget` → a backgrounded
12
+ `run auto --run <id> --until-done`. `nohup`, `2>&1 &`, the log to `tail -f` and "how do I
13
+ check on it later" had zero hits repo-wide; `--ui plain`, the flag that keeps a backgrounded
14
+ log readable, lived in the spec and the changelog only. Three seeds written the same day had
15
+ failed `run new` on the 240-character claim clip (#275), and the rules a seed must follow to
16
+ survive the importer and the `[src:]` grammar existed only in the source. So: a "Zero-touch
17
+ run, start to finish" section at the top of the site's unattended-operation guide (EN + ES)
18
+ with the copyable block, one line per flag — including that `--ship merge` over a base
19
+ branch seen to require no check arms nothing and leaves the PR open for a person (#274) —
20
+ how to check on the run, how to stop
21
+ it (`run cancel` after the process, never before), and the honest boundary: this is the
22
+ happy path without a person, and a run a person signs is a different configuration. The
23
+ same block, six lines, in the README quick start and both quickstarts; `docs/guide/10` has
24
+ its switches, its flags and its cheat sheet brought up to `--until-done`, `--questions`,
25
+ `--ship`, `--gates none` and `_scoped`, and drops a sentence that predated the engine's own
26
+ gate signer; the seed authoring rules — one claim per bullet, ~200 characters with the
27
+ citation, token last, `path:line` that exists, `"; "` between sources, the four What
28
+ headings, a `Recommended:` line per question, `.tldrx/seeds/<nn>-<slug>.md` — are in
29
+ `docs/guide/05` and in the site section, measured against `markdownClaims.ts` and
30
+ `srcToken.ts` rather than remembered. `run auto` examples in the guides now say `--run <id>` (the generated flag reference keeps its positional form), the
31
+ spelling under which a bare `--until-done` cannot mistake the id for its count. The ES
32
+ landing page links the release notes, which stay English and generated (a translated copy
33
+ would be the second copy `gen-changelog.ts` exists to prevent); the untranslated `TLDRX_UI`
34
+ row on the ES flags page is generated from `gen-cli.ts`'s English-by-design env table and
35
+ was left alone.
36
+ ### Fixed
37
+
38
+ - **The dependency frontier is the sequential path's too — nothing fans out over a story a dead
39
+ developer parked (#263).** Measured on a field run (2026-09-12, `run auto --yolo`): S3's
40
+ developer died on its own cap, `parkDeveloperFailure` put the story back at `todo` — deliberate,
41
+ a transport failure settles nothing about the work — and one second later the next wave started
42
+ S4, whose front matter says `depends_on: [S3]`. S4 reached `done` on an epic branch S3 had put
43
+ no line of code on, which is an audit record lying in the dangerous direction: a gate signer
44
+ reads `done` over work that was never delivered. #260 had already built the check that answers
45
+ this (`blockingDependency`: any dependency that is not `done` holds a story, and the story is
46
+ written `blocked` with the reason rather than left quietly at `todo`) and switched it on for
47
+ `--parallel N` only, on the grounds that the sequential loop had no defect of its own. It had
48
+ this one, and `lanes === 1` is the default and what `run auto` runs. Re-measured at 017dda8
49
+ before the change, one fixture, one field moved: at `--parallel 2` the dependent is held with
50
+ the reason `dependency S1 is not done` on `gate.requested`; at the default it is built and
51
+ reported `done`. Now the same one derivation is asked on every N — no second copy of the rule,
52
+ and #260's own direction is kept: a story that depends on nothing still runs after a parked one.
53
+ The park itself, the cap arithmetic and the DoD-decides half of #277 are all untouched.
54
+
55
+ - **The plan's per-story price is a ceiling now, not a wall — and a story that dies on it with
56
+ work in its tree lets the DoD decide (#277).** We shipped this one ourselves, hours earlier:
57
+ #264 landed in 0.18.1 and made the Build executor actually read `03-plan/budget.yml`'s
58
+ per-story prices. Before it nothing did, so the number was decorative and every story got the
59
+ uniform stage share; from #264 on it became the hard ceiling of the developer's turn, at
60
+ `price ÷ (1 + reviewer_share)` = **0.8 × price** — a figure a planner writes before it has
61
+ read a line of the repo. The session running two live unattended runs on 0.18.1 measured the
62
+ consequence (their measurement, not this repo's suite): stories died mid-change on caps a
63
+ dollar or three wide, were left `todo` having spent real money, their dependants went
64
+ `blocked`, and both runs stalled with nothing delivered — the real cost of a story spanning
65
+ roughly an order of magnitude above what the planner had estimated. Two changes, one issue.
66
+ **The cap is now `max(price × story_cap_multiplier, story_cap_floor_usd)`** — defaults 3 and
67
+ $4.00, both new optional `stage.yml` keys alongside `attempts:` and `reviewer_share:`
68
+ (`schemas/stageTuning.ts`), refused by name out of range rather than clamped. 3 NARROWS
69
+ that gap for the low and middle of the range and does not close it: over the prices the
70
+ planner writes today ($1.20-$4.00) the ceiling lands at $4.00-$12.00, so a story at the
71
+ top of the observed spread still dies on it — **a story still dies on its cap whenever
72
+ its real cost exceeds `max(price x 3, $4.00)`**, which is the figure to check before
73
+ recalibrating. The expensive end is carried by the OTHER half below, not by the
74
+ multiplier: the work is committed and measured instead of lost. What neither half covers
75
+ — a stage still reporting `done` over a story that died — is #263, out of scope here. 3
76
+ is chosen against that division of labour and against keeping one story's worst case
77
+ legible in its stage; the floor is the developer-side sibling of
78
+ `REVIEWER_FLOOR_USD` ($1.00) and is a small multiple of it, because the developer reads the
79
+ repo, edits it, runs the story's whole DoD suite and commits where the reviewer reads one
80
+ diff. The error here is not symmetric — a price set too low costs a dead story plus
81
+ everything already paid for it, one set too high costs only the difference on work that lands
82
+ — so the arithmetic is deliberately allowed to over-run a single estimate and the STAGE's own
83
+ budget gate, metered against real spend, is what stops a stage that runs out.
84
+ **Derived at DISPATCH, off the price as it sits on disk, with no migration of `budget.yml`:**
85
+ that is load-bearing rather than tidy, because runs already in flight carry prices written by
86
+ an older planner and only a dispatch-time derivation covers them the moment this is installed.
87
+ `gh #91`'s property survives intact — attempt 1 is the pass the plan priced and gets the whole
88
+ ceiling, the contingency attempt after it gets an `attempts`-th — and the reviewer's share,
89
+ which never came out of the developer's ceiling in the first place, is untouched and now
90
+ visibly additive. The brake's mirror in `budget/remainingWork.ts` moves in step, pinned as
91
+ ever by `test/remaining-work.test.ts`. **And a developer killed by that ceiling with work in
92
+ its tree no longer parks the story `todo`:** it is a story with a diff and no verdict, so the
93
+ facilitator's own Definition of Done decides it — #271's rule over a second cause, the same
94
+ `workSince` tree comparison (committed or not, `tldrx-work/` and `.tldrx/` excluded), the same
95
+ "the cause is recorded either way". The death is named in words with its figure on the story's
96
+ review log, on its `task.done` (`budget_death:`, additive) and in the handoff's `## Unknowns`;
97
+ a red DoD blocks with both reasons on the row; a cap death that left no work still parks the
98
+ story exactly where it was, unchanged since 2026-08-30. The narrowing to a CAP death is
99
+ deliberate: a spawn that never started or a transport fault says nothing about a tree, while
100
+ `Reached maximum budget` says precisely that the turn was working when it stopped. The Plan
101
+ schema contract now tells the planner the price is a ceiling and to budget the expensive case
102
+ rather than the expected one — it is pricing before reading the code, which is exactly when an
103
+ estimate is least worth trusting. What this does NOT fix: a stage that still reports `done`
104
+ while a story died on its cap is #263, measured and filed separately, and is left untouched
105
+ here.
106
+
107
+ - **`--ship merge` no longer arms an auto-merge over a base that requires nothing (#274).**
108
+ Measured on the zero-touch proof run (a field workspace, `--gates none --ship merge`, no human
109
+ in the loop): the PR merged at `06:20:14Z` while all four of its check runs were still pending
110
+ — they had started at 06:20:12/06:20:16/06:20:28 and completed between 06:21:38 and 06:24:56,
111
+ every one of them AFTER the merge — and the ship record said `queued — GitHub merges when its
112
+ checks pass`. The mechanism is a gap between two different sets: the guard counted the checks
113
+ the PR REPORTED (`gh pr view --json statusCheckRollup`), while `gh pr merge --auto` hands the
114
+ wait to GitHub's auto-merge, which waits on the base branch's REQUIREMENTS. That workspace's
115
+ `main` had none (`gh api …/branches/main/protection` → 404 `Branch not protected`), so `--auto`
116
+ did not mean "merge when green", it meant "merge now", and the ledger recorded a red-eligible
117
+ suite as merged on green — a record lying in the dangerous direction (§7). `ship` now asks the
118
+ BASE what it requires before arming anything: `repos/{owner}/{repo}/rules/branches/<base>` for
119
+ rulesets (repository and organization level) and, when that finds none,
120
+ `repos/{owner}/{repo}/branches/<base>/protection` for classic branch protection. Nothing is
121
+ armed without a requirement actually SEEN — `merge: absent — the base branch requires no check
122
+ before merge` when both were read and hold nothing back, and `absent — could not tell what the
123
+ base branch requires` when a probe could not be read, because for a merge "I could not tell" has
124
+ to behave like "there is nothing to wait on". A requirement imposed by a mechanism neither probe
125
+ can see (a merge queue, an org policy this token cannot read) reads as "requires nothing" and so
126
+ arms NOTHING: the residual gap can only ever cost a merge that was not armed, never a merge that
127
+ should not have happened. The `queued` sentence now says what `--auto` actually waits on — the
128
+ base's REQUIRED checks. Two edges of the probe are closed on the same side: a rule that arrives
129
+ carrying an `enforcement` other than `active` is a dry run and is not counted as a requirement
130
+ (GitHub already filters `evaluate`/`disabled` server-side and the response carries no such field
131
+ — measured against the official REST description — so this is belt-and-braces, and its absence is
132
+ read as that documented filter rather than as "unknown", which would refuse to arm on every real
133
+ repo); and only a 404 whose body reads `Branch not protected` means "no classic protection" — a
134
+ branch or a repo that is not there answers 404 too, and reading that as "requires nothing" would
135
+ arm a merge against a base nobody identified.
136
+
3
137
  ## 0.18.1 — 2026-09-13
4
138
 
5
139
  ### Added
package/README.md CHANGED
@@ -41,6 +41,21 @@ tldrx run new payments --scope feature --seed docs/payments/ --budget 25
41
41
  tldrx run auto # `next`, over and over, until something actually needs you
42
42
  ```
43
43
 
44
+ Or hand a small, well-seeded change to the engine with nobody watching — measured once: a one-story
45
+ bugfix went from seed to merged PR in 45 minutes with no human input:
46
+
47
+ ```bash
48
+ tldrx run new login-timeout --scope bugfix --seed .tldrx/seeds/01-login-timeout.md \
49
+ --gates none --questions none --ship pr --budget 40
50
+ nohup tldrx run auto --run <id> --until-done --max-usd 40 \
51
+ --wait-answers 8h --wait-gates 8h --notify-every 30m --ui plain > /tmp/<id>.log 2>&1 &
52
+ ```
53
+
54
+ `--gates none` lets every gate sign itself, `--questions none` lets the loop take each question's own
55
+ `Recommended:` pick, `--ship pr` opens the PR when the run is done. What each flag means, how to write
56
+ the seed, how to check on it and how to stop it:
57
+ [Zero-touch run, start to finish](https://ederwii.github.io/tldr-experts/guides/unattended-operation#zero-touch-run-start-to-finish).
58
+
44
59
  **To run it: Node ≥ 20, and nothing else** — the published package is a pre-built bundle with
45
60
  zero runtime dependencies, and `dist/` is a Node bundle (Bun runs it too, if you have it).
46
61
  **To build or contribute: Bun ≥ 1.3**, which compiles that bundle and runs the test suite.
@@ -316,6 +331,7 @@ back on the registry is 0.3.0.
316
331
 
317
332
  | Version | Date | Status | Contains |
318
333
  |---|---|---|---|
334
+ | 0.18.2 | 2026-09-13 | `beta` | three things measured on the first fully unattended run and on the live runs that followed it, one of them a regression 0.18.1 shipped hours earlier: `--ship merge` armed GitHub's auto-merge over a base that required nothing — the guard counted the checks the PR REPORTED while `--auto` waits on the base's REQUIREMENTS, so a proof run's pull request merged at 06:20:14 while all four of its checks finished between 06:21:38 and 06:24:56, and the record said `queued — GitHub merges when its checks pass`; ship now asks the base what it requires through rulesets and classic protection, names its three absences apart, and every unreadable answer arms nothing; the plan's per-story price had become a hard wall at 0.8x the planner's guess once 0.18.1 made those fields actually read, killing stories mid-flight at $1.28 and $3.25 against real costs an order of magnitude higher, so the ceiling is now `max(price x 3, $4.00)` derived at DISPATCH from the budget as it sits on disk — a run already in flight is covered without migrating anything — and, the half that carries the expensive end, a developer that dies on its cap with work in its tree commits it and lets the Definition of Done decide instead of parking the story, the same authority #271 gave a refused one; and the zero-touch recipe is written down where a person looks for it, seed file to backgrounded loop, on the README quick start and both documentation sites, with the honest boundary that this is the happy path without a person |
319
335
  | 0.18.1 | 2026-09-13 | `beta` | five things a run could not survive on its own, every one measured on the unattended proof runs that followed 0.18.0 rather than reasoned about: a priced plan was never priced — the planner wrote `stories[].estimate_usd` and Build read only `per_phase_usd` + `spent_usd`, so every story got the same $5.40 cap while the plan said $28, and the contract now names the shape and the plan check refuses a budget.yml that does not carry it; a headless refusal under a host-tokens ceiling wrote nothing to the ledger, so `--until-done` relaunched over a refusal it could not see, and now writes its `budget.blocked` first; a developer that had committed its work and was then refused a compound verification command was blocked BEFORE the facilitator ran the Definition of Done, so the DoD now decides when there is work and the refusal is recorded either way, with the prompt saying to run each DoD command verbatim and alone because shell separators split a line into subcommands that each need their own grant; a workspace can declare `<slot>_scoped` DoD commands that run per story on the paths it touched with the full suite once per epic head, after the full suite was measured running three times per story attempt; and a cancelled run no longer leaves an epic branch that refuses the ordinary retry — `run cancel` releases it and Build renames aside an epic whose owner run is closed, while an open or unknown owner still refuses verbatim and an uncounted branch is never deleted |
320
336
  | 0.18.0 | 2026-09-13 | `beta` | the release that lets an unattended run survive its own accidents and end somewhere, every item measured on live runs at 0.16.1 rather than reasoned about: a turn that wrote a lot of files could not be RECORDED — an `agent.result` whose `outputs` field held 3924 bytes of run-relative paths blew the 4096-byte event cap through a RAW append that never reached the capped seam, the throw landed one line after the try/catch built for exactly this and came out as exit 1, which `--retry-failed` does not retry since its branch keys on exit 5, so a loop told to survive two failures survived zero, and the epic branch had been cut on disk one line before the throw with the save after it, leaving the relaunch to read its own epic as a stranger's; `outputs` is now named by COUNT with the full list written beside the event rather than truncated, all four raw appends route through the capped seam at module scope, a throw while recording fails the STAGE with exit 5 saying how many of how many rows landed, the claim is saved the moment it is earned, and `capPayload` still knows its two fields BY NAME — a third growable field has to be taught to it deliberately or it is refused whole; Build entry now proves the Definition of Done can run IN A WORKTREE before a developer is paid, after 18 h from `run auto` to the first story that could run on one workspace, five relaunches, because the repo's `install:` named a file that existed in the human's checkout and was never committed and `git worktree` carries TRACKED FILES ONLY, so every story failed identically after the story was opened and immediately before the paid turn while the base pre-flight was green and right to be, measuring in the checkout where the file is — one throwaway worktree at the base sha, the install run inside it, each DoD command's first token resolved there, NEVER the suite, refusal exit 2 naming the exact path or binary, cached under an additive `worktree:` key narrowed by base sha, declaration hash and AGE (six hours for a green, because the row is a claim about an ENVIRONMENT that no sha contains; the base red's 30 minutes for a red), and costing zero where it could learn nothing; where the two readings could not be told apart it deliberately refuses LESS — a path named by a `dod` command after the first may be an artefact an earlier one builds, so that is an advisory and not a verdict; one blocked story no longer stops every later wave, after a loop told to build 8 stories built 5, never started 3, and reported the stage `done`, twice on the same run, because the parallel path asked only "did any story block" and `depends_on` appeared ZERO times in the Build executor — a story now runs when every one of its `depends_on` is `done`, one held back is `blocked` WITH the reason `dependency S7 blocked` (transitive for free) which is what the gate's `blocked_reason` and the continue note read, the stage line counts every story `waves.yml` scheduled rather than the rows the executor happened to hold, and a scheduled story with no outcome at all is named in `## Unknowns` instead of dropped, while `--parallel 1` is untouched on purpose — a story whose dependency blocked is still attempted there, and changing that would be a second change in the opposite direction; the spawned developer can now delete and rename paths in its own tree, after a story that was simply "delete an unused file" proved undoable — nothing in the allowance could unlink a path or take it out of the index, so every `git rm` came back needing approval nobody was there to give, the refused turn exited 0 with an envelope, the DoD ran green on an untouched tree and the reviewer faulted a diff that was never written, twice — the file-lifecycle git verbs are granted as index operations on the story's OWN tree (never a bare `rm`, `git push` still asserted absent), and a tool call refused for approval now BLOCKS the story at once with the command and why, reaching the story file, the handoff and the gate, so the next missing verb costs one attempt instead of two and nothing; that detector reads a structural field first and the English refusal sentence only as a fenced fallback, because pre-merge review measured the unfenced version reading a plain file-read of a document CONTAINING the phrase as a refusal — this very CHANGELOG is one of them — and a refusal carrying neither signal is a miss taken on purpose, with the sentence half depending on prose the host can change without notice; a fix-list finding now declares what it IS, after three stories with a green definition of done, a merged commit and an APPROVING reviewer settled `blocked`, each on a `fix-now` finding whose whole content was a stale docstring or a citation that did not resolve — the reviewer was not wrong and `severity` was never the vehicle (a free string NO consumer read, one render site under `src`), so a required `kind` splits behaviour from text: `correctness` and `security` hold the story exactly as before, a `docs`/`style` finding submitted `fix-now` is routed to `defer-with-log` with a `Normalised-from:` line, and because this makes a gate WEAKER on purpose the unblocking is priced — declaring a finding `docs` costs the SAME `[src: …]` citation that refuting one already costs, a `kind` that is absent or outside the enum is refused for free and indexed as a fault so it costs the story no attempt, a fix list already on disk with no `Kind:` line reads as not stated and not stated blocks, and the four reviewer-prompt goldens MOVED because the schema alone would have left a reviewer nobody told about the field; `run.yml` can finally explain its own `cost_usd` — a row read `input_tokens: 84, output_tokens: 37150, cost_usd: 1.98` for a 124 KB prompt while the provider reported 4,911,750 cache reads for that same turn, and across 101 transcripts in one workspace cache reads were 97.3% of the input side against a column that saw 0.002% of it — the counters were parsed on every turn and thrown away one function later, and the half the issue did not know about is that the executor path's `agent.result` emitted NO `usage` at all, so every Build and Watch turn lost all four counters and the whole Build was priced off zeroes (measured: zero `usage` lines across the four frozen goldens with ten `agent.result` among them); they are recorded additively and gated one at a time rather than both-or-nothing, `turnTokens` names them while deliberately reading neither so the exclusion is a decision on the record, nothing displayed changed — the dashboard and `tldrx cost` are byte-identical — and a row from before this reads absent, since nothing reconstructs a counter nobody wrote down; a gate about to sign itself is no longer sent as a Yes/No the owner cannot answer, after 3 of 3 questioned stages on two live workspaces sent the prompt 600 ms after the last answer and self-signed on the very next poll, so the one tap it invited ran `approve` on an already-approved gate and, because the in-wait approval fell between two notify windows, the bridge kept the prompt open and re-mentioned its owner every escalate tick for two hours — the release now asks the gate's CONDITIONS through the same re-evaluation the poll uses, extracted so neither can form a second opinion, sends nothing when there is no decision to take and says which stage and why on stdout instead of going quiet, and words a genuinely held gate from that one reading so a payload can no longer name four open questions beside `holding: "none"`; and three things a run could not do before: it can end in a pull request toward main on purpose, frozen at `run new --ship` with `push`, `pr` or `merge` into an additive `run.yml` block and not a sixth phase, pushed through the ONE push wrapper in the codebase with a PR that reports NO check left open and recorded as `merge: absent — no checks to wait on` because auto-merge over nothing to wait on is a merge now rather than a merge when green — pre-merge review caught the first version erasing a recorded merge failure with an empty string and exit 0, and the emitter dropped the whole block until it was taught the key; `questions_policy: recommended` answers a blocking question that carries its own `Recommended:` line and escalates the ones that do not, after 10 owner questions across two headless runs ($89.82 over 28 h and $26.38 over 5.6 h, the agents busy 4.9 h and 56 min of it) parked the loop on 9 that already held the pick in a parsed field, writing `decided_by: agent-default` as a THIRD value beside owner and driver because `owner` there would be an audit record lying in the dangerous direction, and never inventing a pick — no line, a letter naming no option, or a block marked irreversible or money escalates exactly as before; and `run auto --until-done [<n>]` relaunches the loop in-process after an exit it can do nothing else with, at most `n` times (default 5), after five hand relaunches and 18 h before a story ran — never over a person's exit 4, never over a budget block since nothing in-process moves a ceiling, never twice over the same last line, with the run id and the spend baseline pinned across relaunches so `--max-usd` still describes the one command a person typed; the epic-claim-at-cut half of that issue lives in the Build executor and is NOT fixed here |
321
337
  | 0.17.0 | 2026-09-12 | `beta` | three things the framework knew and recorded wrongly, each found by reading its own records rather than by using it: a gate notification now carries the condition HOLDING the gate as data instead of leaving it to be guessed from the text of the command it suggests — `holding` says questions, stories or nothing-mechanical, and when a story is blocked with a recorded reason the payload also hands over a ready-made rejection that lets the loop carry on, with the note DERIVED from the blocked story rather than canned, because a rejection's note is fed to the next turn's prompt and a generic one would hand that turn an empty instruction, so when no reason can be derived the offer is absent rather than invented; the emitted command keeps a literal placeholder and never interpolates the reason, which keeps the quoting hazard out of the record and puts it where a substituting client can see it; `run status` stopped printing more money left than it had ceiling — the run ceiling now has ONE live copy, read from `budget.yml` by every live screen, and `run.yml`'s mirror is documented as the creation value and no longer written by a raise, since the break was never that a raise failed to write the mirror but that an ordinary concurrent save carried a stale copy over it, and the fix was to delete the half-sync rather than to build a better one — pre-merge review caught the first version pairing a LIVE spend with that now-frozen ceiling inside `tldrx replay`, which would have reproduced the same impossible line in a different command on every run whose budget had been raised, measured on the real CLI as `$12.00 spent of $10.00 ceiling` with no concurrency required; and a task row now records the ROLE its turn actually ran under, where every Build turn had been filed as the developer including the reviewer's — the role was known at spawn and written to the event stream, and was dropped on the way to the ledger, so the audit record named the wrong actor for work it had itself measured; the new key is additive and written on EVERY row including the developer's, because a role present only on reviewers would make its absence mean developer-or-not-recorded and send a reader back to inferring the role from an absence, which is the guess the change exists to remove |
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  conflictOf
4
- } from "./chunk-1tynpxvm.js";
4
+ } from "./chunk-nf2j945p.js";
5
5
  import {
6
6
  FactsStore,
7
7
  formatJaccard
@@ -13,11 +13,11 @@ import {
13
13
  import {
14
14
  EventLog,
15
15
  PHASE_ID_RE
16
- } from "./chunk-x64paqbg.js";
16
+ } from "./chunk-gx6qa496.js";
17
17
  import {
18
18
  PHASE_IDS
19
- } from "./chunk-bgxj40rr.js";
20
- import"./chunk-zg7q0q4y.js";
19
+ } from "./chunk-x00a2vaz.js";
20
+ import"./chunk-4y3pk4n2.js";
21
21
  import"./chunk-vf992pfw.js";
22
22
  import {
23
23
  ADVISORY_KEY,
@@ -22,10 +22,10 @@ import {
22
22
  validateRunBudget,
23
23
  wouldExceed,
24
24
  wouldExceedHostTokens
25
- } from "./chunk-ct7kazfy.js";
25
+ } from "./chunk-wx1jce1t.js";
26
26
  import {
27
27
  EventLog
28
- } from "./chunk-x64paqbg.js";
28
+ } from "./chunk-gx6qa496.js";
29
29
  import {
30
30
  cursorStage,
31
31
  hostTokensIn,
@@ -35,7 +35,7 @@ import {
35
35
  renderRunEconomies,
36
36
  runSpend
37
37
  } from "./chunk-hwsng0xq.js";
38
- import"./chunk-zg7q0q4y.js";
38
+ import"./chunk-4y3pk4n2.js";
39
39
  import {
40
40
  noteDeprecations
41
41
  } from "./chunk-8ak5kwd5.js";
@@ -66,13 +66,17 @@ var STAGE_TUNING_DEFAULTS = {
66
66
  attempts: 2,
67
67
  fixlistRounds: 1,
68
68
  reviewerShare: 0.25,
69
- gateSignerShare: 0.25
69
+ gateSignerShare: 0.25,
70
+ storyCapMultiplier: 3,
71
+ storyCapFloorUsd: 4
70
72
  };
71
73
  var STAGE_TUNING_RANGES = {
72
74
  attempts: { key: "attempts", min: 1, max: 5, integer: true },
73
75
  fixlistRounds: { key: "fixlist_rounds", min: 0, max: 3, integer: true },
74
76
  reviewerShare: { key: "reviewer_share", min: 0, max: 1, integer: false },
75
- gateSignerShare: { key: "gate_signer_share", min: 0, max: 1, integer: false }
77
+ gateSignerShare: { key: "gate_signer_share", min: 0, max: 1, integer: false },
78
+ storyCapMultiplier: { key: "story_cap_multiplier", min: 1, max: 20, integer: false },
79
+ storyCapFloorUsd: { key: "story_cap_floor_usd", min: 0, max: 200, integer: false }
76
80
  };
77
81
  var FIELDS = Object.keys(STAGE_TUNING_RANGES);
78
82
  function inTuningRange(range, value) {
@@ -8,7 +8,7 @@ import {
8
8
  spentBasis,
9
9
  tallyOf,
10
10
  validateRunBudget
11
- } from "./chunk-ct7kazfy.js";
11
+ } from "./chunk-wx1jce1t.js";
12
12
  import {
13
13
  EventLog,
14
14
  OUTCOME_NOT_RECORDED,
@@ -21,7 +21,7 @@ import {
21
21
  isTerminal,
22
22
  stageAt,
23
23
  validateRunFile
24
- } from "./chunk-x64paqbg.js";
24
+ } from "./chunk-gx6qa496.js";
25
25
  import {
26
26
  cursorStage,
27
27
  isAttendedByHostView,
@@ -2,7 +2,7 @@ import {
2
2
  GATE_POLICIES,
3
3
  validateGatesPolicy,
4
4
  validateStagePolicy
5
- } from "./chunk-zg7q0q4y.js";
5
+ } from "./chunk-4y3pk4n2.js";
6
6
  import {
7
7
  asDocument,
8
8
  isRecord,
@@ -5,7 +5,7 @@ import {
5
5
  evidencePath,
6
6
  gateEvidencePath,
7
7
  parseEvidence
8
- } from "./chunk-x64paqbg.js";
8
+ } from "./chunk-gx6qa496.js";
9
9
  import {
10
10
  openBlocks,
11
11
  parseQuestions
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  STAGE_TUNING_DEFAULTS
3
- } from "./chunk-zg7q0q4y.js";
3
+ } from "./chunk-4y3pk4n2.js";
4
4
  import {
5
5
  MAX_PLAN_STORIES,
6
6
  MAX_STORIES_PER_WAVE,
@@ -968,8 +968,10 @@ function looksLikeSpawnError(detail) {
968
968
  var MAX_ATTEMPTS = STAGE_TUNING_DEFAULTS.attempts;
969
969
  var REVIEWER_SHARE = STAGE_TUNING_DEFAULTS.reviewerShare;
970
970
  var REVIEWER_FLOOR_USD = 1;
971
- function developerPriceDivisor(attempt, attempts = MAX_ATTEMPTS, reviewerShare = REVIEWER_SHARE) {
972
- return attempt <= 1 ? 1 + reviewerShare : attempts * (1 + reviewerShare);
971
+ var STORY_CAP_MULTIPLIER = STAGE_TUNING_DEFAULTS.storyCapMultiplier;
972
+ var STORY_CAP_FLOOR_USD = STAGE_TUNING_DEFAULTS.storyCapFloorUsd;
973
+ function developerAttemptDivisor(attempt, attempts = MAX_ATTEMPTS) {
974
+ return attempt <= 1 ? 1 : attempts;
973
975
  }
974
976
  function remainingWork(input) {
975
977
  try {
@@ -1094,7 +1096,7 @@ class CapMath {
1094
1096
  const price = this.priceOf(storyId);
1095
1097
  if (price === null)
1096
1098
  return this.agentCap(1 / this.worstCaseShares());
1097
- return this.agentCap(this.shareOf(price / developerPriceDivisor(attempt, this.attempts(), this.reviewerShare())));
1099
+ return this.agentCap(this.shareOf(this.storyCeiling(price) / developerAttemptDivisor(attempt, this.attempts())));
1098
1100
  }
1099
1101
  reviewer(storyId) {
1100
1102
  const price = this.priceOf(storyId);
@@ -1103,6 +1105,11 @@ class CapMath {
1103
1105
  const floor = Math.min(REVIEWER_FLOOR_USD, Math.max(this.input.stageBudgetUsd - this.input.stageSpentUsd, 0));
1104
1106
  return round2(Math.min(Math.max(derived, floor), this.maxBudgetUsd));
1105
1107
  }
1108
+ storyCeiling(price) {
1109
+ const declared = this.input.storyCapFloorUsd ?? STORY_CAP_FLOOR_USD;
1110
+ const floor = this.input.stageBudgetUsd > 0 ? Math.min(declared, this.input.stageBudgetUsd) : declared;
1111
+ return Math.max(price * (this.input.storyCapMultiplier ?? STORY_CAP_MULTIPLIER), floor);
1112
+ }
1106
1113
  priceOf(storyId) {
1107
1114
  const price = this.prices.get(storyId);
1108
1115
  if (price === undefined || !Number.isFinite(price) || price <= 0)
@@ -3,7 +3,7 @@ import {
3
3
  STAGE_TUNING_DEFAULTS,
4
4
  parseWorkflowGates,
5
5
  readStageTuning
6
- } from "./chunk-zg7q0q4y.js";
6
+ } from "./chunk-4y3pk4n2.js";
7
7
  import {
8
8
  PROJECT_FRAMEWORK_DIR,
9
9
  STAGES_DIR,
@@ -20,11 +20,11 @@ import"./chunk-pday823f.js";
20
20
  import {
21
21
  DEFAULT_TIMEOUT_S,
22
22
  buildStageDefaults
23
- } from "./chunk-bgxj40rr.js";
23
+ } from "./chunk-x00a2vaz.js";
24
24
  import {
25
25
  loadRunView
26
26
  } from "./chunk-hwsng0xq.js";
27
- import"./chunk-zg7q0q4y.js";
27
+ import"./chunk-4y3pk4n2.js";
28
28
  import {
29
29
  DodCommandRefused,
30
30
  loadWorkspace,
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  questionsCard
4
- } from "./chunk-1tynpxvm.js";
4
+ } from "./chunk-nf2j945p.js";
5
5
  import"./chunk-kwaqdq9w.js";
6
6
  import {
7
7
  allow,
@@ -20,22 +20,22 @@ import {
20
20
  runSnapshot,
21
21
  statusWithOutcome,
22
22
  whatIsWaiting
23
- } from "./chunk-rmpcrt4c.js";
23
+ } from "./chunk-afttzftj.js";
24
24
  import {
25
25
  expertsDir,
26
26
  loadExperts,
27
27
  pathsIntersect,
28
28
  readExpertDomain,
29
29
  stackExpertNames
30
- } from "./chunk-ct7kazfy.js";
30
+ } from "./chunk-wx1jce1t.js";
31
31
  import {
32
32
  isFinished
33
- } from "./chunk-x64paqbg.js";
34
- import"./chunk-bgxj40rr.js";
33
+ } from "./chunk-gx6qa496.js";
34
+ import"./chunk-x00a2vaz.js";
35
35
  import {
36
36
  openRunViews
37
37
  } from "./chunk-hwsng0xq.js";
38
- import"./chunk-zg7q0q4y.js";
38
+ import"./chunk-4y3pk4n2.js";
39
39
  import"./chunk-vf992pfw.js";
40
40
  import {
41
41
  openBlocks,
@@ -2,12 +2,12 @@
2
2
  import {
3
3
  bar,
4
4
  runSnapshot
5
- } from "./chunk-rmpcrt4c.js";
6
- import"./chunk-ct7kazfy.js";
7
- import"./chunk-x64paqbg.js";
8
- import"./chunk-bgxj40rr.js";
5
+ } from "./chunk-afttzftj.js";
6
+ import"./chunk-wx1jce1t.js";
7
+ import"./chunk-gx6qa496.js";
8
+ import"./chunk-x00a2vaz.js";
9
9
  import"./chunk-hwsng0xq.js";
10
- import"./chunk-zg7q0q4y.js";
10
+ import"./chunk-4y3pk4n2.js";
11
11
  import"./chunk-vf992pfw.js";
12
12
  import"./chunk-8ak5kwd5.js";
13
13
  import {
package/dist/tldrx.js CHANGED
@@ -11893,6 +11893,8 @@ function renderSeedHandoff(input) {
11893
11893
  var SHIP_LEVELS = ["push", "pr", "merge"];
11894
11894
  var AUTO_MERGE_POLICIES = ["never", "checks"];
11895
11895
  var NO_CHECKS_TO_WAIT_ON = "absent — no checks to wait on";
11896
+ var NO_REQUIRED_CHECKS = "absent — the base branch requires no check before merge";
11897
+ var REQUIREMENTS_UNREADABLE = "absent — could not tell what the base branch requires";
11896
11898
  var MERGE_QUEUED = "queued";
11897
11899
 
11898
11900
  class ShipPolicyError extends Error {
@@ -12404,13 +12406,17 @@ var STAGE_TUNING_DEFAULTS = {
12404
12406
  attempts: 2,
12405
12407
  fixlistRounds: 1,
12406
12408
  reviewerShare: 0.25,
12407
- gateSignerShare: 0.25
12409
+ gateSignerShare: 0.25,
12410
+ storyCapMultiplier: 3,
12411
+ storyCapFloorUsd: 4
12408
12412
  };
12409
12413
  var STAGE_TUNING_RANGES = {
12410
12414
  attempts: { key: "attempts", min: 1, max: 5, integer: true },
12411
12415
  fixlistRounds: { key: "fixlist_rounds", min: 0, max: 3, integer: true },
12412
12416
  reviewerShare: { key: "reviewer_share", min: 0, max: 1, integer: false },
12413
- gateSignerShare: { key: "gate_signer_share", min: 0, max: 1, integer: false }
12417
+ gateSignerShare: { key: "gate_signer_share", min: 0, max: 1, integer: false },
12418
+ storyCapMultiplier: { key: "story_cap_multiplier", min: 1, max: 20, integer: false },
12419
+ storyCapFloorUsd: { key: "story_cap_floor_usd", min: 0, max: 200, integer: false }
12414
12420
  };
12415
12421
  var FIELDS = Object.keys(STAGE_TUNING_RANGES);
12416
12422
  function inTuningRange(range, value) {
@@ -15895,7 +15901,7 @@ var ENTRIES = [
15895
15901
  {
15896
15902
  name: "ship",
15897
15903
  arg: "<push|pr|merge>",
15898
- meaning: "How far past the LAST gate the framework may carry the epic branch, frozen into run.yml as `ship:` (#253). `push` publishes `epic/<slug>` to origin; `pr` also opens the pull request `tldrx ship` opens; `merge` also arms `gh pr merge --auto --merge`, so the remote's own checks decide — and a PR that reports NO check is left open with `merge: absent — no checks to wait on`, never merged over silence. It runs when `run auto` sees the run close, or when a person types `tldrx ship`. Absent (the default) nothing is pushed and nothing is opened, exactly as before. Every gate is still signed by whoever `--gates` says: an unattended ship is `--gates none --ship merge`, on purpose, and run.yml records both.",
15904
+ meaning: "How far past the LAST gate the framework may carry the epic branch, frozen into run.yml as `ship:` (#253). `push` publishes `epic/<slug>` to origin; `pr` also opens the pull request `tldrx ship` opens; `merge` also arms `gh pr merge --auto --merge`, so the base branch's own REQUIRED checks decide — and it is armed only over a base SEEN to require one (#274): a PR reporting no check, a base requiring none, and a base whose rulesets and branch protection could not be read all leave the PR open with an `absent — …` reason on the record, never merged over silence. It runs when `run auto` sees the run close, or when a person types `tldrx ship`. Absent (the default) nothing is pushed and nothing is opened, exactly as before. Every gate is still signed by whoever `--gates` says: an unattended ship is `--gates none --ship merge`, on purpose, and run.yml records both.",
15899
15905
  values: ["push", "pr", "merge"],
15900
15906
  sub: "new"
15901
15907
  },
@@ -16480,7 +16486,7 @@ var ENTRIES = [
16480
16486
  ],
16481
16487
  exits: [EXIT_OK, EXIT_USAGE, EXIT_GATE_REFUSED, EXIT_NOT_FOUND],
16482
16488
  notes: [
16483
- "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, in which case it is left open and run.yml says `merge: absent no checks to wait on`. `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.",
16489
+ "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.",
16484
16490
  "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.",
16485
16491
  "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.",
16486
16492
  "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.",
@@ -28747,6 +28753,7 @@ function renderReviewLog(outcome) {
28747
28753
  `- Commit: ${outcome.commit ?? "(none)"}`,
28748
28754
  ...outcome.developerError === null ? [] : [`- Developer: **FAILED** — ${outcome.developerError}`],
28749
28755
  ...outcome.permissionRefused == null ? [] : [`- Developer: \`${outcome.permissionRefused}\` was refused for approval by the agent's own ` + "permission layer; the tree held committed work, so the Definition of Done below decided"],
28756
+ ...outcome.budgetDeath == null ? [] : [`- Developer: died on its per-story cap — ${outcome.budgetDeath}; the tree held work, ` + "so the Definition of Done below decided"],
28750
28757
  "",
28751
28758
  "## Definition of done",
28752
28759
  "",
@@ -28833,8 +28840,10 @@ function renderPreviousAttempt(review) {
28833
28840
  var MAX_ATTEMPTS = STAGE_TUNING_DEFAULTS.attempts;
28834
28841
  var REVIEWER_SHARE = STAGE_TUNING_DEFAULTS.reviewerShare;
28835
28842
  var REVIEWER_FLOOR_USD = 1;
28836
- function developerPriceDivisor(attempt, attempts = MAX_ATTEMPTS, reviewerShare = REVIEWER_SHARE) {
28837
- return attempt <= 1 ? 1 + reviewerShare : attempts * (1 + reviewerShare);
28843
+ var STORY_CAP_MULTIPLIER = STAGE_TUNING_DEFAULTS.storyCapMultiplier;
28844
+ var STORY_CAP_FLOOR_USD = STAGE_TUNING_DEFAULTS.storyCapFloorUsd;
28845
+ function developerAttemptDivisor(attempt, attempts = MAX_ATTEMPTS) {
28846
+ return attempt <= 1 ? 1 : attempts;
28838
28847
  }
28839
28848
  var NAMED_STORIES = 6;
28840
28849
  function remainingWork(input) {
@@ -29004,7 +29013,7 @@ class CapMath {
29004
29013
  const price = this.priceOf(storyId);
29005
29014
  if (price === null)
29006
29015
  return this.agentCap(1 / this.worstCaseShares());
29007
- return this.agentCap(this.shareOf(price / developerPriceDivisor(attempt, this.attempts(), this.reviewerShare())));
29016
+ return this.agentCap(this.shareOf(this.storyCeiling(price) / developerAttemptDivisor(attempt, this.attempts())));
29008
29017
  }
29009
29018
  reviewer(storyId) {
29010
29019
  const price = this.priceOf(storyId);
@@ -29013,6 +29022,11 @@ class CapMath {
29013
29022
  const floor = Math.min(REVIEWER_FLOOR_USD, Math.max(this.input.stageBudgetUsd - this.input.stageSpentUsd, 0));
29014
29023
  return round22(Math.min(Math.max(derived, floor), this.maxBudgetUsd));
29015
29024
  }
29025
+ storyCeiling(price) {
29026
+ const declared = this.input.storyCapFloorUsd ?? STORY_CAP_FLOOR_USD;
29027
+ const floor = this.input.stageBudgetUsd > 0 ? Math.min(declared, this.input.stageBudgetUsd) : declared;
29028
+ return Math.max(price * (this.input.storyCapMultiplier ?? STORY_CAP_MULTIPLIER), floor);
29029
+ }
29016
29030
  priceOf(storyId) {
29017
29031
  const price = this.prices.get(storyId);
29018
29032
  if (price === undefined || !Number.isFinite(price) || price <= 0)
@@ -29319,6 +29333,7 @@ function renderBuildHandoff(parts) {
29319
29333
  const done = parts.outcomes.filter((o) => o.status === "done");
29320
29334
  const notDone = parts.outcomes.filter((o) => o.status !== "done");
29321
29335
  const refusedButMeasured = done.filter((o) => o.permissionRefused != null);
29336
+ const diedButMeasured = done.filter((o) => o.budgetDeath != null);
29322
29337
  const lines = [
29323
29338
  `# Handoff — 04-build / ${parts.stageId} — run ${parts.runId}`,
29324
29339
  `Stage: ${parts.stageId} · Expert: developer + reviewer · Model: ${parts.model ?? "default"} · ` + `Cost: ${spentFigure({
@@ -29337,9 +29352,10 @@ function renderBuildHandoff(parts) {
29337
29352
  "",
29338
29353
  "## Unknowns",
29339
29354
  "",
29340
- ...notDone.length === 0 && (parts.carried ?? []).length === 0 && (parts.unreadableStories ?? []).length === 0 && (parts.foreignWork ?? []).length === 0 && (parts.notStarted ?? []).length === 0 && refusedButMeasured.length === 0 ? [`- none — every scheduled story reached \`done\` and no carried finding is unowned ` + `[src: absent:04-build/log]`] : [],
29355
+ ...notDone.length === 0 && (parts.carried ?? []).length === 0 && (parts.unreadableStories ?? []).length === 0 && (parts.foreignWork ?? []).length === 0 && (parts.notStarted ?? []).length === 0 && refusedButMeasured.length === 0 && diedButMeasured.length === 0 ? [`- none — every scheduled story reached \`done\` and no carried finding is unowned ` + `[src: absent:04-build/log]`] : [],
29341
29356
  ...notDone.map((o) => `- ${o.id} is \`${o.status}\` and needs a human: ${o.reason ?? "see the review"} ` + `[src: ${o.reviewRel}:1]`),
29342
29357
  ...refusedButMeasured.map((o) => `- ${o.id}'s developer had \`${o.permissionRefused ?? ""}\` refused for approval by the agent's own ` + `permission layer; its tree held committed work, so the Definition of Done decided and the story is ` + `\`${o.status}\` — a person may still want to know the command it could not run [src: ${o.reviewRel}:1]`),
29358
+ ...diedButMeasured.map((o) => `- ${o.id}'s developer died on its per-story cap (${o.budgetDeath ?? ""}) with work in its tree, ` + `so the Definition of Done decided and the story is \`${o.status}\` — the plan priced this story ` + `below what it cost, which is worth knowing before the next one [src: ${o.reviewRel}:1]`),
29343
29359
  ...(parts.notStarted ?? []).map((row2) => `- ${row2.id} was scheduled and never started, and is still \`${row2.status}\` — ` + `${row2.reason} [src: ${row2.rel}:1]`),
29344
29360
  ...carriedBullets(parts.carried ?? []),
29345
29361
  ...(parts.unreadableStories ?? []).map((row2) => `- a story file could not be read, so its carried findings were not checked: ` + `\`${row2.rel}\` — ${row2.reason} [src: absent:${row2.rel}]`),
@@ -33072,7 +33088,7 @@ var BUDGET_FIELDS = {
33072
33088
  spent_usd: { value: "0", rule: "`0` — nothing priced here has been spent yet" },
33073
33089
  per_phase_usd: {
33074
33090
  value: "{S1: 4.75}",
33075
- rule: "`<story id>: <usd>`, one entry per scheduled story, every value a number. Keyed by STORY " + "despite the name (the run's own budget.yml keys the same map by phase), and it is the ONLY key " + "anything prices from: a `stories:` list or an `estimate_usd` field validates nothing and prices nothing"
33091
+ rule: "`<story id>: <usd>`, one entry per scheduled story, every value a number. Keyed by STORY " + "despite the name (the run's own budget.yml keys the same map by phase), and it is the ONLY key " + "anything prices from: a `stories:` list or an `estimate_usd` field validates nothing and prices nothing. " + "Each number is a CEILING the story's developer is dispatched under, not a forecast of what it will " + "spend: budget the expensive case, not the expected one. A story that has to read unfamiliar code, " + "run a real test suite and come back from one red is worth several times what a quick edit looks like " + "from here, and stories priced at a couple of dollars are almost always priced too low"
33076
33092
  }
33077
33093
  };
33078
33094
  var EXAMPLE_DOD_COMMAND = "npm run test";
@@ -33222,6 +33238,13 @@ function renderPlanSchemaContract() {
33222
33238
  "",
33223
33239
  ...ruleTable(["version", ...BUDGET_REQUIRED_KEYS], BUDGET_FIELDS),
33224
33240
  "",
33241
+ "Price the EXPENSIVE case. These numbers are read as ceilings, not as estimates: the Build executor",
33242
+ "derives each story's developer ceiling from its price here, so a story priced below what it costs",
33243
+ "dies mid-change having spent the money and delivered nothing, while one priced above it simply",
33244
+ "costs what it costs. The error is not symmetric, so do not write the expected cost — write a high",
33245
+ "percentile of it. You are pricing this before reading the code, which is exactly when an estimate",
33246
+ "is least worth trusting.",
33247
+ "",
33225
33248
  "An optional `economy: host-tokens` at the root says the numbers are tokens, not dollars — it",
33226
33249
  "validates and it prices nothing, because a token figure must never become a dollar cap. Leave",
33227
33250
  "it out when pricing in dollars.",
@@ -36202,8 +36225,10 @@ import { join as join91, relative as relative15 } from "node:path";
36202
36225
  var MAX_ATTEMPTS2 = STAGE_TUNING_DEFAULTS.attempts;
36203
36226
  var REVIEWER_SHARE2 = STAGE_TUNING_DEFAULTS.reviewerShare;
36204
36227
  var REVIEWER_FLOOR_USD2 = 1;
36205
- function developerPriceDivisor2(attempt, attempts = MAX_ATTEMPTS2, reviewerShare = REVIEWER_SHARE2) {
36206
- return attempt <= 1 ? 1 + reviewerShare : attempts * (1 + reviewerShare);
36228
+ var STORY_CAP_MULTIPLIER2 = STAGE_TUNING_DEFAULTS.storyCapMultiplier;
36229
+ var STORY_CAP_FLOOR_USD2 = STAGE_TUNING_DEFAULTS.storyCapFloorUsd;
36230
+ function developerAttemptDivisor2(attempt, attempts = MAX_ATTEMPTS2) {
36231
+ return attempt <= 1 ? 1 : attempts;
36207
36232
  }
36208
36233
  var DEFAULT_PARALLEL = 1;
36209
36234
  function clampParallel(requested) {
@@ -36214,6 +36239,12 @@ function clampParallel(requested) {
36214
36239
  function attemptsOf(parts) {
36215
36240
  return parts.attempts ?? MAX_ATTEMPTS2;
36216
36241
  }
36242
+ function storyCapMultiplierOf(parts) {
36243
+ return parts.storyCapMultiplier ?? STORY_CAP_MULTIPLIER2;
36244
+ }
36245
+ function storyCapFloorOf(parts) {
36246
+ return parts.storyCapFloorUsd ?? STORY_CAP_FLOOR_USD2;
36247
+ }
36217
36248
  function reviewerShareOf(parts) {
36218
36249
  return parts.reviewerShare ?? REVIEWER_SHARE2;
36219
36250
  }
@@ -36224,7 +36255,11 @@ function developerCap(parts, storyId, attempt = 1) {
36224
36255
  const price = priceOf(parts, storyId);
36225
36256
  if (price === null)
36226
36257
  return parts.agentCap(1 / worstCaseShares(parts));
36227
- return parts.agentCap(shareOf(parts, price / developerPriceDivisor2(attempt, attemptsOf(parts), reviewerShareOf(parts))));
36258
+ return parts.agentCap(shareOf(parts, storyCeilingUsd(parts, price) / developerAttemptDivisor2(attempt, attemptsOf(parts))));
36259
+ }
36260
+ function storyCeilingUsd(parts, price) {
36261
+ const floor = parts.budgetUsd > 0 ? Math.min(storyCapFloorOf(parts), parts.budgetUsd) : storyCapFloorOf(parts);
36262
+ return Math.max(price * storyCapMultiplierOf(parts), floor);
36228
36263
  }
36229
36264
  function reviewerCap(parts, spentUsd, storyId) {
36230
36265
  const price = priceOf(parts, storyId);
@@ -38647,6 +38682,7 @@ class BuildSession {
38647
38682
  tasks = [];
38648
38683
  outcomes = new Map;
38649
38684
  refusals = new Map;
38685
+ capDeaths = new Map;
38650
38686
  epics = new EpicState;
38651
38687
  lines = [];
38652
38688
  counters = new ReviewCounters;
@@ -38696,7 +38732,7 @@ class BuildSession {
38696
38732
  this.lines.push(` · ${planned.story.id} is already \`${status}\` — left alone`);
38697
38733
  continue;
38698
38734
  }
38699
- const held2 = this.lanes === 1 ? null : this.blockingDependency(planned);
38735
+ const held2 = this.blockingDependency(planned);
38700
38736
  if (held2 !== null) {
38701
38737
  this.blockOnDependency(planned, held2);
38702
38738
  continue;
@@ -39040,6 +39076,7 @@ class BuildSession {
39040
39076
  async buildHalf(planned) {
39041
39077
  const before = this.statusOf(planned);
39042
39078
  this.refusals.delete(planned.story.id);
39079
+ this.capDeaths.delete(planned.story.id);
39043
39080
  const story = await this.writes.run(() => this.openStory(planned, true));
39044
39081
  await this.writes.run(() => {
39045
39082
  this.ctx.emit("task.started", {
@@ -39066,16 +39103,28 @@ class BuildSession {
39066
39103
  }
39067
39104
  const handed = await headSha(story.worktree);
39068
39105
  const developer = await this.spawnDeveloper(story);
39106
+ const capDeath = developer.error !== null && diedOnCap(developer.error);
39107
+ let budgetDeath = null;
39069
39108
  if (developer.error !== null) {
39070
- return {
39071
- story,
39072
- cost: developer.cost,
39073
- dod: [],
39074
- commit: null,
39075
- failure: null,
39076
- developerError: developer.error,
39077
- before
39078
- };
39109
+ const proven = capDeath && await workSince({
39110
+ workspaceRoot: this.workspace.root,
39111
+ repoDir: story.repoDir,
39112
+ worktree: story.worktree,
39113
+ since: handed
39114
+ });
39115
+ if (!proven) {
39116
+ return {
39117
+ story,
39118
+ cost: developer.cost,
39119
+ dod: [],
39120
+ commit: null,
39121
+ failure: null,
39122
+ developerError: developer.error,
39123
+ before
39124
+ };
39125
+ }
39126
+ budgetDeath = developer.error;
39127
+ this.capDeaths.set(story.planned.story.id, developer.error);
39079
39128
  }
39080
39129
  const spent = developer.cost;
39081
39130
  if (developer.refused !== null) {
@@ -39108,7 +39157,11 @@ class BuildSession {
39108
39157
  cost: spent,
39109
39158
  dod,
39110
39159
  commit: null,
39111
- failure: developer.refused === null ? why : `${why}; and ${permissionBlockReason(developer.refused)}`,
39160
+ failure: [
39161
+ why,
39162
+ ...developer.refused === null ? [] : [permissionBlockReason(developer.refused)],
39163
+ ...budgetDeath === null ? [] : [capDeathReason(budgetDeath)]
39164
+ ].join("; and "),
39112
39165
  developerError: null,
39113
39166
  before
39114
39167
  };
@@ -39919,6 +39972,7 @@ class BuildSession {
39919
39972
  reviewRel,
39920
39973
  reason: parts.reason,
39921
39974
  permissionRefused: this.refusals.get(id) ?? null,
39975
+ budgetDeath: this.capDeaths.get(id) ?? null,
39922
39976
  rescued,
39923
39977
  cost_usd: parts.cost
39924
39978
  };
@@ -39948,7 +40002,8 @@ class BuildSession {
39948
40002
  commit: parts.commit,
39949
40003
  attempt: story.attempt,
39950
40004
  ...parts.epicBase === null || parts.epicBase === undefined || parts.epicBase === "" ? {} : { epic_base: parts.epicBase },
39951
- ...outcome.permissionRefused == null ? {} : { permission_refused: outcome.permissionRefused }
40005
+ ...outcome.permissionRefused == null ? {} : { permission_refused: outcome.permissionRefused },
40006
+ ...outcome.budgetDeath == null ? {} : { budget_death: outcome.budgetDeath }
39952
40007
  });
39953
40008
  if (pruning && (rescued === null || rescued.worktree === null)) {
39954
40009
  await removeWorktree(story.repoDir, story.worktree);
@@ -40472,7 +40527,9 @@ class BuildSession {
40472
40527
  maxBudgetUsd: this.ctx.maxBudgetUsd,
40473
40528
  agentCap: this.ctx.agentCap,
40474
40529
  attempts: this.attempts,
40475
- reviewerShare: this.ctx.spec.tuning.reviewerShare
40530
+ reviewerShare: this.ctx.spec.tuning.reviewerShare,
40531
+ storyCapMultiplier: this.ctx.spec.tuning.storyCapMultiplier,
40532
+ storyCapFloorUsd: this.ctx.spec.tuning.storyCapFloorUsd
40476
40533
  };
40477
40534
  }
40478
40535
  get attempts() {
@@ -40644,6 +40701,12 @@ function developerTools(repoCommands, options = {}) {
40644
40701
  function permissionBlockReason(command2) {
40645
40702
  return `permission — \`${command2}\` was refused for approval by the agent's own permission layer, ` + "and a headless turn has nobody to approve it: the same allowance would refuse it again, " + "so this attempt was not repeated";
40646
40703
  }
40704
+ function diedOnCap(error) {
40705
+ return error.includes("Reached maximum budget");
40706
+ }
40707
+ function capDeathReason(error) {
40708
+ return `the developer died on its per-story cap — ${error}: the ceiling is derived from ` + "`03-plan/budget.yml`'s price for this story, so a story that really costs more than the " + "plan guessed wants a higher price there or a higher `story_cap_multiplier:` on the stage";
40709
+ }
40647
40710
  var REVIEWER_TOOLS = ["Read", "Grep", "Glob", "Bash(git diff *)"];
40648
40711
  function summaryOf(envelope) {
40649
40712
  return typeof envelope.summary === "string" ? envelope.summary : "";
@@ -41337,7 +41400,9 @@ function stageRemainingWork(store, options, phaseId, stage2, spec) {
41337
41400
  economy: economyFor(store.budget, phaseId),
41338
41401
  attended: isAttendedByHost(store.run),
41339
41402
  attempts: spec.tuning.attempts,
41340
- reviewerShare: spec.tuning.reviewerShare
41403
+ reviewerShare: spec.tuning.reviewerShare,
41404
+ storyCapMultiplier: spec.tuning.storyCapMultiplier,
41405
+ storyCapFloorUsd: spec.tuning.storyCapFloorUsd
41341
41406
  });
41342
41407
  }
41343
41408
  async function runExecutor(store, options, phaseId, stageId, spec, notes, executor) {
@@ -42558,7 +42623,7 @@ async function shipOne(options, store, branch, repo, body, excuses, policy) {
42558
42623
  }
42559
42624
  const existing = policy === null ? null : await openPrFor(options, repo, branch);
42560
42625
  if (existing !== null && policy !== null) {
42561
- const merge2 = await armOrKeep(options, repo, existing, policy, previousMerges(store));
42626
+ const merge2 = await armOrKeep(options, repo, existing, policy, previousMerges(store), base2);
42562
42627
  const record4 = writeShipRecord(store, policy, [existing], { [repo.name]: merge2 }, options.at);
42563
42628
  return {
42564
42629
  code: merge2.startsWith("failed") ? EXIT_GATE_REFUSED : EXIT_OK,
@@ -42587,7 +42652,7 @@ async function shipOne(options, store, branch, repo, body, excuses, policy) {
42587
42652
  ];
42588
42653
  if (policy === null)
42589
42654
  return { code: EXIT_OK, lines };
42590
- const merge = await armMerge(options, repo, url, policy);
42655
+ const merge = await armMerge(options, repo, url, policy, base2);
42591
42656
  const record3 = writeShipRecord(store, policy, url === null ? [] : [url], { [repo.name]: merge }, options.at);
42592
42657
  return {
42593
42658
  code: merge.startsWith("failed") ? EXIT_GATE_REFUSED : EXIT_OK,
@@ -42602,15 +42667,22 @@ function policyLines(prepared, branch, merge) {
42602
42667
  ];
42603
42668
  }
42604
42669
  function describeMerge(merge) {
42605
- if (merge === MERGE_QUEUED)
42606
- return `${MERGE_QUEUED} — \`gh pr merge --auto --merge\`; GitHub merges when its checks pass`;
42670
+ if (merge === MERGE_QUEUED) {
42671
+ return `${MERGE_QUEUED} — \`gh pr merge --auto --merge\`; GitHub merges when the base's REQUIRED checks pass`;
42672
+ }
42607
42673
  if (merge === NO_CHECKS_TO_WAIT_ON)
42608
42674
  return `${NO_CHECKS_TO_WAIT_ON} — the PR stays open for a person`;
42675
+ if (merge === NO_REQUIRED_CHECKS) {
42676
+ return `${NO_REQUIRED_CHECKS} — \`--auto\` would merge at once; the PR stays open for a person`;
42677
+ }
42678
+ if (merge === REQUIREMENTS_UNREADABLE) {
42679
+ return `${REQUIREMENTS_UNREADABLE} — nothing was armed; the PR stays open for a person`;
42680
+ }
42609
42681
  if (merge === "never")
42610
42682
  return "never (ship.auto_merge)";
42611
42683
  return merge;
42612
42684
  }
42613
- async function armMerge(options, repo, url, policy) {
42685
+ async function armMerge(options, repo, url, policy, base2) {
42614
42686
  if (policy.auto_merge !== "checks")
42615
42687
  return "never";
42616
42688
  if (url === null)
@@ -42620,16 +42692,21 @@ async function armMerge(options, repo, url, policy) {
42620
42692
  return `failed — could not read the PR's checks: ${checks.detail}`;
42621
42693
  if (checks.count === 0)
42622
42694
  return NO_CHECKS_TO_WAIT_ON;
42695
+ const required = await requiredChecks(options, repo, base2);
42696
+ if (required.kind === "unreadable")
42697
+ return REQUIREMENTS_UNREADABLE;
42698
+ if (!required.required)
42699
+ return NO_REQUIRED_CHECKS;
42623
42700
  const armed = await options.transport.run(GH_BIN, ["pr", "merge", url, "--auto", "--merge"], repo.dir);
42624
42701
  if (armed.exitCode !== 0) {
42625
42702
  return `failed — \`gh pr merge --auto\` exited ${String(armed.exitCode)}` + `${firstLine10(armed.stderr) === "" ? "" : `: ${firstLine10(armed.stderr)}`}`;
42626
42703
  }
42627
42704
  return MERGE_QUEUED;
42628
42705
  }
42629
- async function armOrKeep(options, repo, url, policy, previous) {
42706
+ async function armOrKeep(options, repo, url, policy, previous, base2) {
42630
42707
  if (policy.auto_merge === "checks" && previous[repo.name] === MERGE_QUEUED)
42631
42708
  return MERGE_QUEUED;
42632
- return await armMerge(options, repo, url, policy);
42709
+ return await armMerge(options, repo, url, policy, base2);
42633
42710
  }
42634
42711
  function previousMerges(store) {
42635
42712
  return store.run.ship?.merges ?? {};
@@ -42655,6 +42732,63 @@ async function checksReported(options, repo, url) {
42655
42732
  await new Promise((resolve12) => setTimeout(resolve12, Math.min(SHIP_CHECKS_POLL_MS, grace)));
42656
42733
  }
42657
42734
  }
42735
+ var ENFORCEMENT_ACTIVE = "active";
42736
+ var BRANCH_NOT_PROTECTED = "Branch not protected";
42737
+ var RULES_ENDPOINT = (base2) => `repos/{owner}/{repo}/rules/branches/${base2}`;
42738
+ var PROTECTION_ENDPOINT = (base2) => `repos/{owner}/{repo}/branches/${base2}/protection`;
42739
+ async function requiredChecks(options, repo, base2) {
42740
+ const rules = await rulesRequireChecks(options, repo, base2);
42741
+ if (rules.kind === "unreadable" || rules.required)
42742
+ return rules;
42743
+ return await protectionRequiresChecks(options, repo, base2);
42744
+ }
42745
+ async function rulesRequireChecks(options, repo, base2) {
42746
+ const seen = await options.transport.run(GH_BIN, ["api", RULES_ENDPOINT(base2)], repo.dir);
42747
+ if (seen.exitCode !== 0) {
42748
+ return { kind: "unreadable", detail: firstLine10(seen.stderr) || `gh exited ${String(seen.exitCode)}` };
42749
+ }
42750
+ let doc;
42751
+ try {
42752
+ doc = JSON.parse(seen.stdout);
42753
+ } catch {
42754
+ return { kind: "unreadable", detail: "`gh api …/rules/branches` printed something that is not JSON" };
42755
+ }
42756
+ if (!Array.isArray(doc))
42757
+ return { kind: "unreadable", detail: "`gh api …/rules/branches` did not print an array" };
42758
+ const required = doc.some((rule) => {
42759
+ const row2 = rule;
42760
+ if (row2?.type !== "required_status_checks")
42761
+ return false;
42762
+ if (row2.enforcement !== undefined && row2.enforcement !== ENFORCEMENT_ACTIVE)
42763
+ return false;
42764
+ const contexts = row2.parameters?.required_status_checks;
42765
+ return Array.isArray(contexts) && contexts.length > 0;
42766
+ });
42767
+ return { kind: "read", required };
42768
+ }
42769
+ async function protectionRequiresChecks(options, repo, base2) {
42770
+ const seen = await options.transport.run(GH_BIN, ["api", PROTECTION_ENDPOINT(base2)], repo.dir);
42771
+ let doc;
42772
+ try {
42773
+ doc = JSON.parse(seen.stdout);
42774
+ } catch {
42775
+ doc = null;
42776
+ }
42777
+ if (seen.exitCode !== 0) {
42778
+ const body = doc;
42779
+ const notProtected = String(body?.status ?? "") === "404" && body?.message === BRANCH_NOT_PROTECTED;
42780
+ if (notProtected)
42781
+ return { kind: "read", required: false };
42782
+ return { kind: "unreadable", detail: firstLine10(seen.stderr) || `gh exited ${String(seen.exitCode)}` };
42783
+ }
42784
+ if (doc === null) {
42785
+ return { kind: "unreadable", detail: "`gh api …/branches/…/protection` printed something that is not JSON" };
42786
+ }
42787
+ const rule = doc.required_status_checks;
42788
+ const contexts = Array.isArray(rule?.contexts) ? rule.contexts.length : 0;
42789
+ const checks = Array.isArray(rule?.checks) ? rule.checks.length : 0;
42790
+ return { kind: "read", required: contexts + checks > 0 };
42791
+ }
42658
42792
  function writeShipRecord(store, policy, prUrls, merges, at) {
42659
42793
  const all = { ...previousMerges(store), ...merges };
42660
42794
  const distinct = [...new Set(Object.values(all))];
@@ -42694,7 +42828,7 @@ async function shipMany(options, store, branch, repos, body, excuses, policy) {
42694
42828
  kind: "existing",
42695
42829
  url: already,
42696
42830
  base: prepared.base,
42697
- ...policy === null ? {} : { merge: await armOrKeep(options, repo, already, policy, previousMerges(store)) }
42831
+ ...policy === null ? {} : { merge: await armOrKeep(options, repo, already, policy, previousMerges(store), prepared.base) }
42698
42832
  });
42699
42833
  continue;
42700
42834
  }
@@ -42718,7 +42852,7 @@ async function shipMany(options, store, branch, repos, body, excuses, policy) {
42718
42852
  kind: "opened",
42719
42853
  base: prepared.base,
42720
42854
  url,
42721
- ...policy === null ? {} : { merge: await armMerge(options, repo, url ?? null, policy) }
42855
+ ...policy === null ? {} : { merge: await armMerge(options, repo, url ?? null, policy, prepared.base) }
42722
42856
  });
42723
42857
  }
42724
42858
  const rendered = renderMany(store, branch, body, results, options.dryRun === true);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tldr-experts",
3
- "version": "0.18.1",
3
+ "version": "0.18.2",
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.18.1",
5
+ "version": "0.18.2",
6
6
  "author": {
7
7
  "name": "Alan Martinez"
8
8
  }