tldr-experts 0.33.0 → 0.34.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,193 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.34.0 — 2026-09-17
4
+
5
+ ### Fixed
6
+
7
+ - **`budget show`, `run estimate` and the `budget-gate` hook now price a Build stage's
8
+ `reviewer_share`, `story_cap_multiplier` and `story_cap_floor_usd` the same way `tldrx next`'s
9
+ brake does, instead of the shipped defaults (see #333).** #214 fixed exactly one of the four
10
+ `stage.yml` calibrations `remainingWork` takes — `attempts` — leaving these three still
11
+ defaulted by the three readers while the brake resolved all four through `spec.tuning`.
12
+ Measured against the Build fixture (three stories priced 2.00/2.50/4.50, two done, stage
13
+ ceiling $60): a stage that writes `story_cap_multiplier: 1` ("take the plan's price
14
+ literally") was quoted $24.25 by `budget show`/`run estimate`/the hook against the $10.75 the
15
+ brake would actually price and dispatch — 2.3x — with the phase ceiling able to sit between
16
+ the two, so `budget show` said BLOCKED and the hook denied a `tldrx next` the brake itself
17
+ allows. `buildStageDefaults` (`core/run/workflowPreset.ts`) now resolves and returns the
18
+ stage's full `StageTuning`, not just `attempts` and `timeout_s`, by re-reading the same
19
+ `stage.yml` `facilitator/stageSpec.ts`'s `overlay` already reads — tolerant exactly as before,
20
+ so an unreadable stage file still falls back to the shipped defaults. `run estimate` already
21
+ held the full tuning via `loadStageSpec`; it was just not forwarding three of the four fields
22
+ to `remainingWork`.
23
+ - **`round2` and the spec §3 exit-code table are each defined in exactly one place, with a shape
24
+ test refusing a second copy (see #174).** `round2` (`Math.round(n * 100) / 100`) was
25
+ independently restated in nine files; the spec §3 exit codes (`cli/exitCodes.ts`) were
26
+ independently restated, under a mix of matching and diverging names (`EXIT_REFUSED` for
27
+ `EXIT_GATE_REFUSED`, `EXIT_AWAITING` for `EXIT_AWAITING_HUMAN`), in fourteen more — wider than
28
+ the issue's own grep found, which only named `runNext.ts`/`runAuto.ts`; the broader set was
29
+ measured here (`grep -rlE '^const EXIT_[A-Z_]+\s*=\s*[0-9]+;' src/`) and folded into the same
30
+ fix rather than filed separately, since it is the identical defect the issue is about. Every
31
+ local copy now imports the canonical one (`core/build/caps.ts`'s exported `round2`,
32
+ `cli/exitCodes.ts`'s table, aliased where a file's own name differed) instead of restating it.
33
+ Mechanism this was hiding: a golden-guard mutation of `cli/exitCodes.ts` left
34
+ `test/build-golden.test.ts` byte-identical, because the Build path never read that file's
35
+ constants at all — it read `runNext.ts`'s and `runAuto.ts`'s own private copies. The new
36
+ `test/round2-exitcodes-one-derivation.test.ts` asserts, on the SHAPE (a local, unexported
37
+ `function round2(`/`const EXIT_*` declaration) rather than on the measured names, that no file
38
+ under `src/` besides the two canonical ones defines either — on the precedent of
39
+ `test/phase-ids-one-derivation.test.ts` (#187): deleting today's copies does not stop a third
40
+ one from coming back under a name this file never mentions.
41
+ - **`merge-wave.sh --status` now says why it could not look instead of answering `idle` (see
42
+ #332).** AGENTS.md §2 calls this command the authoritative instrument for "is a wave alive" —
43
+ the reference-transaction guard and the merge lock exist precisely so nobody has to `ps` plus
44
+ a marker's mtime. Measured on the first autonomous run against this repo (2026-09-17): run
45
+ from outside a git repository, or with its `merge-lock.sh` sibling missing, it answered `idle`
46
+ and exit 0 — a false all-clear that could start a wave on top of one already live. It now
47
+ prints `unknown: <reason>` and exits **15**, its own code (`14` stays release.sh's — see
48
+ AGENTS.md §2 and docs/RELEASING.md), so a caller polling stdout can no longer read "could not
49
+ check" as "nothing is happening".
50
+ - **`merge-wave.sh` now diffs a released, dated CHANGELOG section's content, not just its
51
+ heading, so a rebase-then-merge across a release can no longer land a bullet inside it
52
+ silently.** The exit-12 gate only ever counted unreleased headings; a branch written before
53
+ a release and rebased onto main after that release merged CLEANLY — no conflict, no heading
54
+ touched — and its bullet landed inside the now-dated, supposedly immutable section, caught
55
+ only by a human reading the diff or much later by `release-check.sh`'s own #200 check. The
56
+ wave now compares every dated section against what main had immediately before the merge and
57
+ refuses with its own exit **16**, naming the section and the first difference (see #336).
58
+ - **A spawning test run whose `$TMPDIR` sits inside a git work tree can no longer commit its
59
+ scratch paths onto the enclosing branch (#335).** Fixtures resolve their sandbox through
60
+ `os.tmpdir()`/`$TMPDIR` and run real git inside it; when that path is inside a worktree,
61
+ git's own upward repo search walks out of the fixture and finds the WORKTREE's repo, so
62
+ `git add`/`git commit` there land on whatever branch is checked out — #335 measured 960
63
+ tracked scratch paths and dozens of foreign commits on a real branch this way, and the same
64
+ underlying mechanism reproduced here on `490f637` from a fresh throwaway worktree as the same
65
+ two test failures the issue reported (`test/gitignore-shadow.test.ts`,
66
+ `test/install.test.ts`). The `bun test` preload `test/preload/tmpdirGuard.ts` now refuses,
67
+ named, before any test runs, when the resolved `$TMPDIR` is inside a git work tree — opt out
68
+ only with `TLDRX_ALLOW_TMPDIR_IN_WORKTREE=1`, once the isolation is verified some other way.
69
+ - **A turn killed mid-flight by SIGINT/SIGTERM now banks its cost as absent, not a measured
70
+ `$0.00` (see #355).** `interrupt.ts`'s `recordPartialResult` wrote `cost_usd: 0` with no
71
+ `metered` key on both the task row and the `agent.result` payload — legal by `RunFile.ts`'s
72
+ validator, which only ever checked the converse (a `null` needs `metered: false`), so it
73
+ passed silently while `tallyOf` summed the row into the metered bucket as a real, measured
74
+ zero and `spendBasis` called the run's total complete. It now writes `cost_usd: null,
75
+ metered: false` on the row and the payload, the same idiom every other unknown-cost turn in
76
+ this codebase already uses, so a Ctrl-C mid-turn reads as a stated lower bound.
77
+ - **A killed parent's abandoned Build-story turn now leaves a row in `run.yml`, not nothing (see
78
+ #337).** The headless half of this shipped earlier as the orphan case #337 was filed for; the
79
+ fix deliberately skipped every Build-executor phase, reasoning the per-story ceiling ledger
80
+ already covered it — true for `tldrx cost`'s per-story view, but that ledger has no `run.yml`
81
+ task row to show for it, so `tallyOf`/`spendBasis` (and so `budget.spent_usd`) never saw the
82
+ abandoned attempt at all, not even as unmetered. `demoteStaleRunning` now reads which of a
83
+ stuck Build stage's `agent.spawned` turns no `agent.result` ever answered
84
+ (`build/orphanedTurns.ts`) and records one `cost_usd: null, metered: false` row per abandoned
85
+ story, same as the headless case.
86
+ - **The boundary gate now diffs the branch Build actually cut under `branch_model: integration`,
87
+ instead of an epic file's unused per-epic `branch:` label (see #225).** `epicTargets` read
88
+ `03-plan/epics/*.md`'s own `branch:` as the ref to diff — right under `per-epic`, where Build
89
+ cuts exactly that name, and silently wrong under `integration`, where every epic's stories
90
+ merge into ONE recorded branch and the epic's `branch:` is never cut at all. A run with eight
91
+ such epics measured `boundary=n/a (nothing could be diffed: … does not resolve …)` and
92
+ auto-signed a +31,465/−237-line PR across 117 files with the condition having diffed nothing.
93
+ The branch now comes from `run.yml`'s `build.epic_branch`, through the same `recordedEpicBranch`
94
+ derivation `watch` and `ship` already use (#90) — the epic file supplies repos and stories,
95
+ never the ref. A run that recorded an epic branch and still cannot diff it now HOLDS the gate
96
+ instead of reading `n/a`; `n/a` stays reserved for a run that named no branch to diff at all.
97
+ - **The boundary gate reads a run's stories through the same validated, all-phases walk `ship`
98
+ and carried-findings ownership already share, instead of its own `03-plan/`-only, schema-less
99
+ reader (see #189).** `boundary.ts`'s `storyTouches` was a third `stories/*.md` reader: it
100
+ looked only under `03-plan/` (missing a story filed under `04-build/`, the phase a carried fix
101
+ list's own story is written under), parsed front matter with no schema (taking a story a real
102
+ reader would report as unreadable), and dropped an unparseable file silently. It now calls the
103
+ shared `scanStories`/`phaseDirsOf` leaf (extracted from `build/carriedRows.ts` into
104
+ `build/storyScan.ts` so `boundary.ts` — which `carriedRows.ts` already imports from — does not
105
+ import it back), closing a divergence `carriedRows.ts` had documented as "somebody else's
106
+ issue" since #171.
107
+ - **An agent gate can no longer sign over evidence dated before the stage it is closing even
108
+ started (see #144).** A field evaluation of a delegated `watch` gate found its evidence note
109
+ timestamped 7h before its stage began, asserting facts already false by the time it signed,
110
+ with `refuted: 0` — the resolution half of that bug was #140; this is the remaining half
111
+ AGENTS.md §7 names: "a gate's evidence must not predate what it signs". `validateEvidence`
112
+ refuses a note whose `at:` is before the CURRENT attempt's own start — the new, additive
113
+ `RunStage.attempt_started_at`, which `markRunning` overwrites on every run, never the frozen
114
+ `started_at` it sits beside (§7: existing fields only grow, never change meaning; `started_at`
115
+ is set once, at the stage's first run, and stays that forever) — on both the engine's own
116
+ signer and `approve --as-agent`, one derivation, both doors. Two more holes pre-merge review
117
+ found in the same check: `at` was only confirmed non-empty, so a raw string compare read a
118
+ date-only `at` as earlier than a same-day, full-precision stage start; `at` is now confirmed a
119
+ real RFC3339 instant first, and the two timestamps are compared as instants, not strings. And
120
+ the full test gate caught the new check's own collateral: `tldrx learn`'s chapter 6 planted its
121
+ stand-in evidence note with a fixed past date, which the real wall clock had by now walked
122
+ past — its `at:` is stamped off `Date.now()` (with a 5-minute forward buffer, since the note is
123
+ written before the step that starts the stage it signs) rather than a date fixed at authoring
124
+ time.
125
+ - **`tldrx reject` now archives a stage's scratch evidence note instead of leaving it for the
126
+ next attempt to sign over (see #199).** A reject sent a stage back to `ready` but never
127
+ touched `.agent/<stage>/evidence.md`, so a note that had already said `verdict: sign` over one
128
+ attempt's outputs was still the first (and only) thing either signer found after the stage
129
+ re-ran with different outputs — and could sign again over an attempt it never read. The note
130
+ is renamed to `evidence.rejected-<at>.md` in the same scratch directory (moved, not deleted:
131
+ it is still the record of a real check somebody made), so the re-run meets the ordinary "no
132
+ evidence note at …" state instead. `reject` also advances the stage's `attempt_started_at`
133
+ anchor to its own moment (see #144's entry above) — closing a gap pre-merge review found:
134
+ without it, `approve --evidence <path>` pointed straight at the just-archived note still
135
+ signed, since nothing yet had moved the anchor past it. Archiving is collision-safe too
136
+ (`-2`, `-3`, … suffixes): two rejections landing in the same clock second used to let the
137
+ second silently overwrite the first's archive.
138
+ - **`reject --stage` (i.e. a revoke) no longer strands a later stage `running` with no verb that
139
+ owns it, and `run unlock`'s stranded-run note no longer promises a demotion `next` does not
140
+ make (see #228).** A revoke marked every later stage `stale: true` but left `status: running`
141
+ untouched; `next` only demotes a `running` stage behind a DEAD-PID lock, and a revoke leaves no
142
+ lock behind at all, so the cursor could walk back into that stranded stage mid-flight once the
143
+ revoked stage was re-approved. A later `running` stage with nothing holding it (no `--prepare`
144
+ bundle waiting) now goes back to `ready` alongside going stale, named on the outcome and on the
145
+ additive `gate.revoked` event; one holding a bundle is a sub-agent turn already paid for, so
146
+ the revoke refuses instead and names it. `run unlock` had the same false promise for a
147
+ lockless `running` stage ("`tldrx next` demotes it") — it now performs that demotion itself
148
+ when nothing is holding the run, rather than describing a move nothing makes. A revoke also
149
+ advances `attempt_started_at` (see #144, #199) on the stage it revokes and on every stage it
150
+ demotes, for the same reason `reject` does.
151
+ - **A mutating generator command in a story's dod block can no longer be run by the base-tree
152
+ pre-flight as a routine measurement (see #362).** Measured live, tldrx 0.31.1: a `.NET`
153
+ story's dod block declared `dotnet ef migrations add …` beside ordinary build/test commands,
154
+ and the base-tree probe ran it against the UNTOUCHED base tree — a DELTA-gate measurement is
155
+ not supposed to mutate the tree it is measuring, and it would have written a migration triple
156
+ into pristine `main` as a side effect of asking a question. `isGeneratorCommand`
157
+ (`schemas/commandAllowlist.ts`) detects a generator BY NAME — the command text, not a
158
+ workspace-declared slot key, since a team names its migration-add slot whatever it likes — and
159
+ two doors read that one derivation: `validateStoryDod` now refuses a generator command at Plan
160
+ time, so a plan is never written this way, and `dodRunner.ts`'s `measureBaseCommand` refuses
161
+ to run one against either base tree it measures, as defense in depth for a story file that
162
+ reached Build some other way. Neither door touches a story's own fresh worktree, where a
163
+ generator legitimately runs once, by the developer.
164
+ - **`tldrx next` now self-closes a parked `auto` gate the moment every condition it was held on
165
+ clears, `--wait-gates` or not (see #342).** Before this, the only door that ever re-measured an
166
+ already-`awaiting_gate` stage was `run auto --wait-gates`'s own poll (`waitForGate` →
167
+ `selfCloseAutoGate`); without that flag, `run auto` answering its own blocking questions (or a
168
+ person answering them by hand) left the gate `awaiting_gate` forever, and the next `next` call
169
+ reported it as terminal-for-this-call with a generic `gate pending: tldrx approve` — never
170
+ noticing the questions it was parked on had since been answered. `next` now re-runs the same
171
+ seven conditions (`reevaluateAutoGate`) on every `awaiting_gate` stage whose policy is `auto`
172
+ before giving up, and signs it through the same `approve` door a freshly-closed gate uses —
173
+ `human` and `agent` gates are untouched, and a verdict that still fails falls through to the
174
+ ordinary human-gate report exactly as before.
175
+ - **The default Build budget gate now parks at a story boundary instead of stopping the stage
176
+ incomplete with no boundary the operator chose (see #354).** `runNext.ts`'s `budgetRefusal`
177
+ meters the phase ceiling exactly once, at stage entry — the Build executor itself never
178
+ re-checked the envelope between two dispatches of one headless run, so a phase that could
179
+ afford its entry estimate but ran short partway through kept dispatching until the money was
180
+ gone, and the only way the gate fired again was an all-or-nothing refusal on a RETRY of a
181
+ failed stage. Modeled directly on #298's rate-limit park: before starting a new story's
182
+ developer, the executor now checks whether the stage's remainder still covers that story's
183
+ floor and, if not, lets whatever is already running finish and settle, then stops — recording
184
+ one `budget.parked` event (mirroring `agent.rate_limited`'s shape) and naming the shortfall as
185
+ the reason in both the operator line and the handoff's `## Unknowns`. `run auto --wait-gates`
186
+ resumes the parked stage itself once an operator's `tldrx budget raise --stage` (or
187
+ `--rebalance-finished`) actually raises the STAGE's own `budget_usd` — checked fresh on every
188
+ poll against the phase's real remainder, never a clock — and `run status` names the park
189
+ instead of reading as a decision waiting on a person.
190
+
3
191
  ## 0.33.0 — 2026-09-17
4
192
 
5
193
  ### Added
package/README.md CHANGED
@@ -335,6 +335,7 @@ back on the registry is 0.3.0.
335
335
 
336
336
  | Version | Date | Status | Contains |
337
337
  |---|---|---|---|
338
+ | 0.34.0 | 2026-09-17 | `beta` | Four waves of the audited backlog, every branch reviewed before main: run auto no longer stops itself (parked auto gates self-close, Build parks at a story boundary on budget), gates cannot sign stale or backdated evidence, interrupted turns bank their cost as absent instead of a measured zero, the boundary condition diffs the branch the run recorded, mutating generators are refused from dod blocks, merge-wave refuses edits to released CHANGELOG sections and answers `unknown` instead of `idle`, and the test harness refuses a TMPDIR inside a work tree — 14 issues, the first of them shipped end-to-end by a tldrx run against this repo |
338
339
  | 0.33.0 | 2026-09-17 | `beta` | Six follow-ups from the 0.32.0 cycle, each measured on the second unattended run (run #42) or on the review that shipped it. The plan sequencing rule counts a bare camelCase field only when it is unambiguous or the plan spells it elsewhere as snake_case or in backticks, so unrelated stories sharing a word like `toString` no longer refuse each other (#370); `task.done` clamps an oversized `as_is_note` the way it clamps `permission_refused` (#368); the asked-no-diff and red-DoD requeues settle through one helper that takes data (#369); a workspace can opt its base pre-flight probe into a story-shaped worktree with `probe_in_worktree: true`, and the Build-entry probe runs `tool_restore:` first (#371, the remainder of #363); AGENTS §11 names the negated closing verb that still auto-closes and widens the pre-wave grep (#372); and CI keeps merge-wave's sandboxes on failure so the next #237 red carries its `merge.log` (#373). Minor release: a new workspace field and a new plan refusal shape. |
339
340
  | 0.32.0 | 2026-09-16 | `beta` | Seven framework defects measured on the second unattended run (run #42), where every one of nine operator stalls was the framework's, not the code's. A refused compound shell line is a rework note for the developer instead of a human stop (#360); a dependency hold is re-evaluated the moment the dependency lands (#361) and a rescued story is written back to `todo` the instant the release is decided (#366); an event payload over the size cap is clamped to a bounded head instead of failing the whole build stage (#359); a headless developer is told it is unattended, that `## Inputs` is where to start and not an allowlist, and a turn that asks instead of deciding is a named `asked_no_diff` failure (#364); the rate-limit park waits for 90% of the window, not the provider's first warning at half of it (#367); the plan refuses a story that enforces an invariant on a field before the story that populates it, matching the field across spellings (#365); and the preflight restores workspace tools before its first probe and names the tree it ran in (#363, in part). Minor release: a new plan refusal, a changed park threshold and a new failure kind. |
340
341
  | 0.31.1 | 2026-09-16 | `beta` | One fix, measured on the first unattended run under 0.31.0: a seed placed at the documented `.tldrx/seeds/` location was invisible to the seed-solution detector, because the filter that drops the `what` stage's own framework inputs dropped every `.tldrx/` path, so `how` ran at full price for exactly the seeds #346 was written to skip (#358). |
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  conflictOf
4
- } from "./chunk-px7swzs1.js";
4
+ } from "./chunk-9kzevq39.js";
5
5
  import {
6
6
  FactsStore,
7
7
  formatJaccard
@@ -14,10 +14,10 @@ import"./chunk-ae6bkfs5.js";
14
14
  import {
15
15
  EventLog,
16
16
  PHASE_ID_RE
17
- } from "./chunk-5wh26b5f.js";
17
+ } from "./chunk-4d22xm3c.js";
18
18
  import {
19
19
  PHASE_IDS
20
- } from "./chunk-vhy24kv6.js";
20
+ } from "./chunk-kcrphpwc.js";
21
21
  import"./chunk-7ypyp8xk.js";
22
22
  import {
23
23
  ADVISORY_KEY,
@@ -14,11 +14,9 @@ import {
14
14
  REBALANCE_SOURCE,
15
15
  applyRebalance,
16
16
  asRunBudget,
17
- currentActor,
18
17
  describeRebalance,
19
18
  economyFor,
20
19
  isHostTokens,
21
- nowRfc3339,
22
20
  planRebalance,
23
21
  raiseCommand,
24
22
  raiseGrantVerdict,
@@ -27,10 +25,12 @@ import {
27
25
  validateRunBudget,
28
26
  wouldExceed,
29
27
  wouldExceedHostTokens
30
- } from "./chunk-s4kz5nn2.js";
28
+ } from "./chunk-dw8gggv9.js";
31
29
  import {
32
- EventLog
33
- } from "./chunk-5wh26b5f.js";
30
+ EventLog,
31
+ currentActor,
32
+ nowRfc3339
33
+ } from "./chunk-4d22xm3c.js";
34
34
  import {
35
35
  cursorStage,
36
36
  hostTokensIn,
@@ -42,7 +42,7 @@ import {
42
42
  } from "./chunk-57s4mdhy.js";
43
43
  import {
44
44
  buildStageDefaults
45
- } from "./chunk-vhy24kv6.js";
45
+ } from "./chunk-kcrphpwc.js";
46
46
  import {
47
47
  noteDeprecations
48
48
  } from "./chunk-sjykymar.js";
@@ -125,7 +125,8 @@ await runHook("budget-gate", async () => {
125
125
  const attended = isAttendedByHostView(view);
126
126
  const stage = cursorStage(view);
127
127
  const declared = stage?.budget_usd ?? stageBudgetFromLibrary(root, view.cursor.stage);
128
- const attempts = buildStageDefaults(root, view.scope, view.cursor.stage).attempts;
128
+ const tuning = buildStageDefaults(root, view.scope, view.cursor.stage);
129
+ const attempts = tuning.attempts;
129
130
  const work = declared === null ? null : remainingWork({
130
131
  runDir: view.dir,
131
132
  phaseId: view.cursor.phase,
@@ -135,7 +136,10 @@ await runHook("budget-gate", async () => {
135
136
  maxUsd: null,
136
137
  economy: economyFor(budget, view.cursor.phase),
137
138
  attended,
138
- attempts
139
+ attempts,
140
+ reviewerShare: tuning.reviewerShare,
141
+ storyCapMultiplier: tuning.storyCapMultiplier,
142
+ storyCapFloorUsd: tuning.storyCapFloorUsd
139
143
  });
140
144
  const estimate = estimateFor(command, work === null ? null : work.usd);
141
145
  if (estimate <= 0)
@@ -2,7 +2,7 @@ import {
2
2
  GATE_POLICIES,
3
3
  validateGatesPolicy,
4
4
  validateStagePolicy
5
- } from "./chunk-vhy24kv6.js";
5
+ } from "./chunk-kcrphpwc.js";
6
6
  import {
7
7
  asDocument,
8
8
  isRecord,
@@ -69,6 +69,7 @@ var EVENT_TYPES = [
69
69
  "budget.blocked",
70
70
  "budget.raised",
71
71
  "budget.granted",
72
+ "budget.parked",
72
73
  "fact.added",
73
74
  "fact.retired",
74
75
  "fact.superseded",
@@ -231,6 +232,19 @@ function skippedNote(skipped) {
231
232
  }
232
233
  var WARNED = new Set;
233
234
 
235
+ // src/hooks/lib/actor.ts
236
+ function currentActor() {
237
+ const user = process.env.USER ?? process.env.USERNAME ?? "";
238
+ return user.trim() === "" ? "unknown" : user.trim();
239
+ }
240
+ function nowRfc3339() {
241
+ return `${new Date().toISOString().slice(0, 19)}Z`;
242
+ }
243
+ var RFC3339_RE = /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
244
+ function isRfc3339(value) {
245
+ return RFC3339_RE.test(value) && !Number.isNaN(Date.parse(value));
246
+ }
247
+
234
248
  // src/core/run/questionsPolicy.ts
235
249
  var QUESTION_POLICIES = ["human", "recommended"];
236
250
  function validateQuestionsPolicy(value, stageIds, issues) {
@@ -300,6 +314,9 @@ function parseEvidence(text) {
300
314
  const role = requireEnum2(doc.role, EVIDENCE_ROLES, "role", issues);
301
315
  const by = requireText(doc.by, "by", issues);
302
316
  const at = requireText(doc.at, "at", issues);
317
+ if (at !== "" && !isRfc3339(at)) {
318
+ issues.push(front(0, `\`at\` must be a valid RFC3339 timestamp, got \`${at}\``));
319
+ }
303
320
  const verdict = requireEnum2(doc.verdict, EVIDENCE_VERDICTS, "verdict", issues);
304
321
  const read = requireStrings(doc.read, "read", issues);
305
322
  const citations = requireCitations(doc.citations, issues);
@@ -728,6 +745,9 @@ function validateRunFile(input) {
728
745
  if (stage.stale !== undefined && typeof stage.stale !== "boolean") {
729
746
  issues.push({ path: `${path}.stale`, message: "expected true or false" });
730
747
  }
748
+ if (stage.attempt_started_at !== undefined && stage.attempt_started_at !== null && typeof stage.attempt_started_at !== "string") {
749
+ issues.push({ path: `${path}.attempt_started_at`, message: "expected a string or null" });
750
+ }
731
751
  checkOrder(stage.started_at, stage.ended_at, path, issues);
732
752
  if (cursor !== null && cursor.phase === phaseId && cursor.stage === stageId)
733
753
  cursorResolves = true;
@@ -918,4 +938,4 @@ function gateEvidencePath(runDir, phaseId, stageId) {
918
938
  return join2(runDir, ...gateEvidenceRelPath(phaseId, stageId).split("/"));
919
939
  }
920
940
 
921
- export { EventLog, parseEvidence, PHASE_ID_RE, OUTCOME_NOT_RECORDED, isTerminal, isFinished, isAttendedByHost, flatten, stageAt, deriveRunStatus, derivePhaseStatus, validateRunFile, asRunFile, evidencePath, gateEvidencePath };
941
+ export { EventLog, currentActor, nowRfc3339, parseEvidence, PHASE_ID_RE, OUTCOME_NOT_RECORDED, isTerminal, isFinished, isAttendedByHost, flatten, stageAt, deriveRunStatus, derivePhaseStatus, validateRunFile, asRunFile, evidencePath, gateEvidencePath };
@@ -6,12 +6,11 @@ import {
6
6
  WAVES_FILE,
7
7
  asRunBudget,
8
8
  buildProgress,
9
- nowRfc3339,
10
9
  spentBasis,
11
10
  storyDependsOn,
12
11
  tallyOf,
13
12
  validateRunBudget
14
- } from "./chunk-s4kz5nn2.js";
13
+ } from "./chunk-dw8gggv9.js";
15
14
  import {
16
15
  EventLog,
17
16
  OUTCOME_NOT_RECORDED,
@@ -22,9 +21,10 @@ import {
22
21
  isAttendedByHost,
23
22
  isFinished,
24
23
  isTerminal,
24
+ nowRfc3339,
25
25
  stageAt,
26
26
  validateRunFile
27
- } from "./chunk-5wh26b5f.js";
27
+ } from "./chunk-4d22xm3c.js";
28
28
  import {
29
29
  cursorStage,
30
30
  isAttendedByHostView,
@@ -163,6 +163,9 @@ function stage(s) {
163
163
  ];
164
164
  if (s.stale === true)
165
165
  lines.push(" stale: true");
166
+ if (s.attempt_started_at !== undefined && s.attempt_started_at !== null) {
167
+ lines.push(` attempt_started_at: ${yamlScalar(s.attempt_started_at)}`);
168
+ }
166
169
  if (s.tasks.length === 0) {
167
170
  lines.push(" tasks: []");
168
171
  } else {
@@ -795,6 +798,7 @@ function statusWithOutcome(status, outcome) {
795
798
  // src/core/run/autoGate.ts
796
799
  var AUTO_GATE_ACTOR = "auto";
797
800
  var RATE_LIMIT_RESUME_NOTE_PREFIX = "run auto resumed this stage: its auto gate was held by an unfinished " + "story a rate-limit warning parked, and the provider's own clock has since cleared it.";
801
+ var BUDGET_RESUME_NOTE_PREFIX = "run auto resumed this stage: its auto gate was held by an unfinished " + "story a budget shortfall parked, and the phase can now afford it.";
798
802
  var RETRYABLE_CONDITIONS = new Set(["checks", "claim-sources"]);
799
803
  var AUTO_GATE_REFUSED_PREFIX = "auto-gate refused — held by: ";
800
804
  var HELD_SEPARATOR = " · ";
@@ -1350,6 +1354,48 @@ function currentRateLimitPark(runDir, stageId) {
1350
1354
  return park;
1351
1355
  }
1352
1356
 
1357
+ // src/core/run/budgetPark.ts
1358
+ function currentBudgetPark(runDir, stageId) {
1359
+ let events;
1360
+ try {
1361
+ events = EventLog.forRun(runDir).readAll().events;
1362
+ } catch {
1363
+ return null;
1364
+ }
1365
+ let stageStartIndex = -1;
1366
+ for (let index = events.length - 1;index >= 0; index -= 1) {
1367
+ const event = events[index];
1368
+ if (event !== undefined && event.stage === stageId && event.type === "stage.started") {
1369
+ stageStartIndex = index;
1370
+ break;
1371
+ }
1372
+ }
1373
+ if (stageStartIndex === -1)
1374
+ return null;
1375
+ let park = null;
1376
+ for (let index = events.length - 1;index >= stageStartIndex; index -= 1) {
1377
+ const event = events[index];
1378
+ if (event === undefined || event.type !== "budget.parked" || event.stage !== stageId)
1379
+ continue;
1380
+ const payload = event.payload;
1381
+ const parked = payload.parked;
1382
+ if (typeof parked !== "string" || parked === "")
1383
+ continue;
1384
+ if (typeof payload.remainder_usd !== "number" || typeof payload.floor_usd !== "number")
1385
+ continue;
1386
+ park = { remainderUsd: payload.remainder_usd, floorUsd: payload.floor_usd, parked };
1387
+ break;
1388
+ }
1389
+ if (park === null)
1390
+ return null;
1391
+ const view = storiesView(runDir);
1392
+ if (view === null || view.unfinished.length === 0)
1393
+ return null;
1394
+ if (view.unfinished.some((story) => story.status !== "todo"))
1395
+ return null;
1396
+ return park;
1397
+ }
1398
+
1353
1399
  // src/core/run/waiting.ts
1354
1400
  var MOVABLE_KINDS = [
1355
1401
  "gate",
@@ -1408,6 +1454,14 @@ function waitingFor(run, runDir) {
1408
1454
  questions: open
1409
1455
  };
1410
1456
  }
1457
+ const budgetPark = currentBudgetPark(runDir, entry.stage.id);
1458
+ if (budgetPark !== null) {
1459
+ return {
1460
+ kind: "gate",
1461
+ message: `gate on ${entry.phase.id}/${entry.stage.id} — parked by a budget shortfall ` + `($${budgetPark.remainderUsd.toFixed(2)} left, $${budgetPark.floorUsd.toFixed(2)} needed) ` + "— resumes automatically once the phase can afford it (`tldrx budget raise`, " + "or `run auto --rebalance-finished`)",
1462
+ questions: open
1463
+ };
1464
+ }
1411
1465
  }
1412
1466
  return {
1413
1467
  kind: "gate",
@@ -5,7 +5,7 @@ import {
5
5
  evidencePath,
6
6
  gateEvidencePath,
7
7
  parseEvidence
8
- } from "./chunk-5wh26b5f.js";
8
+ } from "./chunk-4d22xm3c.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-vhy24kv6.js";
3
+ } from "./chunk-kcrphpwc.js";
4
4
  import {
5
5
  MAX_PLAN_STORIES,
6
6
  MAX_STORIES_PER_WAVE,
@@ -28,15 +28,6 @@ import {
28
28
  result
29
29
  } from "./chunk-0a3b8w1c.js";
30
30
 
31
- // src/hooks/lib/actor.ts
32
- function currentActor() {
33
- const user = process.env.USER ?? process.env.USERNAME ?? "";
34
- return user.trim() === "" ? "unknown" : user.trim();
35
- }
36
- function nowRfc3339() {
37
- return `${new Date().toISOString().slice(0, 19)}Z`;
38
- }
39
-
40
31
  // src/core/budget/spentFigure.ts
41
32
  var SPENT_BASES = ["lower-bound", "complete"];
42
33
  function spentBasis(unmetered) {
@@ -229,6 +220,9 @@ var REVIEWER_SHARE = STAGE_TUNING_DEFAULTS.reviewerShare;
229
220
  var REVIEWER_FLOOR_USD = 2;
230
221
  var STORY_CAP_MULTIPLIER = STAGE_TUNING_DEFAULTS.storyCapMultiplier;
231
222
  var STORY_CAP_FLOOR_USD = STAGE_TUNING_DEFAULTS.storyCapFloorUsd;
223
+ function round2(n) {
224
+ return Math.round(n * 100) / 100;
225
+ }
232
226
  function shortBy(estimate, remaining) {
233
227
  return Math.max(0.01, Math.ceil((estimate - remaining) * 100) / 100);
234
228
  }
@@ -1214,9 +1208,6 @@ function reviewEventErrored(payload) {
1214
1208
  return false;
1215
1209
  return typeof payload.detail === "string" && looksLikeReviewerError(payload.detail);
1216
1210
  }
1217
- function round2(n) {
1218
- return Math.round(n * 100) / 100;
1219
- }
1220
1211
 
1221
1212
  // src/core/budget/wouldExceed.ts
1222
1213
  function totalSpent(budget) {
@@ -1451,4 +1442,4 @@ function round6(n) {
1451
1442
  return Math.round(n * 100) / 100;
1452
1443
  }
1453
1444
 
1454
- 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, buildProgress, storyDependsOn, shortBy, remainingWork, wouldExceed, wouldExceedHostTokens, raiseGrantVerdict, REBALANCE_SOURCE, planRebalance, applyRebalance, describeRebalance, raiseCommand };
1445
+ export { 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, buildProgress, storyDependsOn, shortBy, remainingWork, wouldExceed, wouldExceedHostTokens, raiseGrantVerdict, REBALANCE_SOURCE, planRebalance, applyRebalance, describeRebalance, raiseCommand };
@@ -122,13 +122,14 @@ var PHASE_IDS = ["01-what", "02-how", "03-plan", "04-build", "05-watch"];
122
122
  var PHASE_SEGMENT_RE = /^0[1-5]-[a-z]+$/;
123
123
  var DEFAULT_TIMEOUT_S = 7200;
124
124
  function buildStageDefaults(root, scope, stageId) {
125
- const fallback = { attempts: STAGE_TUNING_DEFAULTS.attempts, timeoutS: DEFAULT_TIMEOUT_S };
125
+ const fallback = { ...STAGE_TUNING_DEFAULTS, timeoutS: DEFAULT_TIMEOUT_S };
126
126
  try {
127
127
  const preset = loadWorkflowPreset(root, scope);
128
128
  const stage = (stageId === undefined ? undefined : preset.stages.find((s) => s.id === stageId)) ?? preset.stages.find((s) => s.phase === PHASE_IDS[3]);
129
129
  if (stage === undefined)
130
130
  return fallback;
131
- return { attempts: stage.attempts, timeoutS: stage.timeout_s };
131
+ const doc = existsSync(stage.source) ? parseYaml(readFileSync(stage.source, "utf8")) : null;
132
+ return { ...readStageTuning(doc), timeoutS: stage.timeout_s };
132
133
  } catch {
133
134
  return fallback;
134
135
  }
@@ -23,7 +23,7 @@ import {
23
23
  import {
24
24
  DEFAULT_TIMEOUT_S,
25
25
  buildStageDefaults
26
- } from "./chunk-vhy24kv6.js";
26
+ } from "./chunk-kcrphpwc.js";
27
27
  import {
28
28
  DodCommandRefused,
29
29
  loadWorkspace,
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  questionsCard
4
- } from "./chunk-px7swzs1.js";
4
+ } from "./chunk-9kzevq39.js";
5
5
  import"./chunk-bfvcebn6.js";
6
6
  import {
7
7
  allow,
@@ -20,7 +20,7 @@ import {
20
20
  runSnapshot,
21
21
  statusWithOutcome,
22
22
  whatIsWaiting
23
- } from "./chunk-4w2e56zw.js";
23
+ } from "./chunk-5q89ffsn.js";
24
24
  import"./chunk-ae6bkfs5.js";
25
25
  import {
26
26
  expertsDir,
@@ -28,14 +28,14 @@ import {
28
28
  pathsIntersect,
29
29
  readExpertDomain,
30
30
  stackExpertNames
31
- } from "./chunk-s4kz5nn2.js";
31
+ } from "./chunk-dw8gggv9.js";
32
32
  import {
33
33
  isFinished
34
- } from "./chunk-5wh26b5f.js";
34
+ } from "./chunk-4d22xm3c.js";
35
35
  import {
36
36
  openRunViews
37
37
  } from "./chunk-57s4mdhy.js";
38
- import"./chunk-vhy24kv6.js";
38
+ import"./chunk-kcrphpwc.js";
39
39
  import"./chunk-7ypyp8xk.js";
40
40
  import {
41
41
  openBlocks,
@@ -2,12 +2,12 @@
2
2
  import {
3
3
  bar,
4
4
  runSnapshot
5
- } from "./chunk-4w2e56zw.js";
5
+ } from "./chunk-5q89ffsn.js";
6
6
  import"./chunk-ae6bkfs5.js";
7
- import"./chunk-s4kz5nn2.js";
8
- import"./chunk-5wh26b5f.js";
7
+ import"./chunk-dw8gggv9.js";
8
+ import"./chunk-4d22xm3c.js";
9
9
  import"./chunk-57s4mdhy.js";
10
- import"./chunk-vhy24kv6.js";
10
+ import"./chunk-kcrphpwc.js";
11
11
  import"./chunk-7ypyp8xk.js";
12
12
  import"./chunk-sjykymar.js";
13
13
  import {