tldr-experts 0.15.0 → 0.16.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,56 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.0 — 2026-09-11
4
+
5
+ ### Added
6
+
7
+ - **`tldrx run auto --retry-failed <n>` — a bounded retry on a failed stage (#233).** Measured
8
+ on a real unattended run at 0.15.0: the loop drove itself through what → how → plan and
9
+ signed all three `auto` gates by itself, and still needed a person four times. Three of the
10
+ four were content decisions or money, which a loop must not make. The fourth was a plan that
11
+ failed its `plan` check by five characters over a cap — a person relaunched `run auto`, the
12
+ next attempt fixed the two files and passed, and nothing else happened. So the loop stopped
13
+ on the one failure it could have cleared, and it stopped because `run auto` returned on every
14
+ non-zero exit except `4`. Now `--retry-failed <n>` lets it run that stage again, at most `n`
15
+ times in a row. It bounds exit `5` and nothing else: a usage error (`1`), a money refusal
16
+ (`2`) and an awaiting-human park (`4`) are each attempted ONCE however large `n` is, because
17
+ a phase ceiling means *a human decides about money* and a retry would turn that sentence into
18
+ a delay. Only CONSECUTIVE failures count — any other outcome puts the count back to zero,
19
+ since what is bounded is "this run is stuck", not "this run has ever failed". A retry SPENDS:
20
+ it is a fresh metered stage under the same phase ceiling and the same `--max-usd`, which is
21
+ what stops it running up a bill. `0` is the default and a default invocation's lines are
22
+ byte-identical to what they were; anything outside `0..3` is refused by name with exit `1`,
23
+ from the one constant the loop and the flag parser share. When the bound is spent the loop
24
+ stops on the failure's own exit `5` and SAYS the count last — `3 consecutive stage failures
25
+ at 03-plan/plan …` — so the sentence that reaches a phone is what the loop tried, not a bare
26
+ `5`.
27
+
28
+ ### Fixed
29
+
30
+ - **An `auto` gate that REFUSES now writes the verdict down, so `run status` says which of the
31
+ seven conditions is holding it (#230).** The note has always been the designed answer to
32
+ "which of the seven stopped it" — and it was written only by a gate that CLOSED, so the one
33
+ record built for that question was `note: ""` in exactly the case it exists for. Measured
34
+ 2026-09-10 on an unattended `run auto`: a gate sat pending ~40 minutes, `run status` and
35
+ `run status --verbose` named no condition, and the reason (`claim-sources`, one unresolvable
36
+ source) surfaced only when a person guessed at the `tldrx approve` the status line suggested
37
+ — the one route nobody unattended is going to take. Each re-measure that refuses now records
38
+ `auto-gate refused — held by: <ids> · <all seven with their values>` on the gate that is
39
+ still `pending`, and `run status` names the ids on the gate row and on the `waiting` line
40
+ (`--verbose` still quotes the whole note). All seven values, not just the failures: a note
41
+ that dropped `budget=$0.30 of $6.00` would answer "was it the money" with the same silence.
42
+ It writes only a `pending` gate — a gate a person has since signed keeps THEIR words — and
43
+ only when the note would change, so a four-hour `--wait-gates` poll writes `run.yml` once per
44
+ distinct verdict rather than thousands of times. That `pending` test and the write are a
45
+ **compare-and-set under the workspace lock**, over a `run.yml` read inside it: a check-then-act
46
+ over a snapshot taken a moment earlier erased a concurrent `approve` outright — `save()` writes
47
+ the whole snapshot and re-reads only `budget.yml`'s ceilings, so the gate fell back to
48
+ `pending`, `by`/`at` to null and the person's words were gone. Caught in pre-merge review and
49
+ reproduced with two real processes, which is now the test: the poll runs every two seconds
50
+ precisely while a person is deciding, so an audit record that destroys the evidence of a human
51
+ decision is not a theoretical interleaving. A gate carrying a refusal note is no longer
52
+ counted among the "signed gates carry a note" rows, because nobody closed it.
53
+
3
54
  ## 0.15.0 — 2026-09-10
4
55
 
5
56
  ### Added
package/README.md CHANGED
@@ -316,6 +316,7 @@ back on the registry is 0.3.0.
316
316
 
317
317
  | Version | Date | Status | Contains |
318
318
  |---|---|---|---|
319
+ | 0.16.0 | 2026-09-11 | `beta` | an unattended run can now clear the one kind of failure it was stopping on, and a gate that refuses says why it refused: measured 2026-09-10 on a real unattended `run auto`, the loop drove itself through what → how → plan and signed all three `auto` gates by itself, and still needed a person four times — three of those were content or money decisions a loop must not make, and the fourth was a plan that failed its own check by five characters over a cap, where a person relaunched the same command and the next attempt fixed the two files and passed, so the loop stopped on the one failure it could have cleared; `tldrx run auto --retry-failed <n>` now runs a failed stage again at most `n` times in a row, bounding exit `5` and nothing else — a usage error (`1`), a money refusal (`2`) and an awaiting-human park (`4`) are each attempted ONCE however large `n` is, because a phase ceiling means a human decides about money and a retry would turn that sentence into a delay — only CONSECUTIVE failures count since what is bounded is "this run is stuck" and not "this run has ever failed", a retry SPENDS as a fresh metered stage under the same phase ceiling and the same `--max-usd`, `0` is the default and a default invocation's lines are byte-identical to what they were, and when the bound is spent the loop stops on the failure's own exit `5` and says the count LAST, so the sentence that reaches a phone is what the loop tried and not a bare number; and an `auto` gate that REFUSES now writes down the verdict its note was always designed to carry — a gate sat pending ~40 minutes while `run status` and `--verbose` named no condition at all, and the reason surfaced only when a person guessed at the `tldrx approve` the status line suggested, which is the one route nobody unattended is going to take — recording all seven conditions WITH THEIR VALUES on the still-`pending` gate, since a note that dropped the passing ones would answer "was it the money" with the same silence, and naming the holding ids on the gate row and on the `waiting` line; it writes only over a `pending` gate, so a gate a person has since signed keeps THEIR words, and only when the verdict would change, so a four-hour `--wait-gates` poll writes once per distinct verdict rather than thousands of times — and that test and that write are a compare-and-set under the workspace lock, because pre-merge review reproduced, with two real processes, a check-then-act over an earlier snapshot erasing a concurrent `approve` outright, and the poll runs every two seconds precisely while a person is deciding |
319
320
  | 0.15.0 | 2026-09-10 | `beta` | defaults for the models actually running today, and records that name what happened: measured 2026-09-07/09 across three real workspaces, the first engine-driven run of each was ended by a calibration rather than by the work — a `how` turn and two Build developer turns killed at a 900 s per-turn clock while Opus turns on real repositories run 15-50 minutes, a 202 KB prompt refused by a ceiling whose own message called it "29% of a 200k window", and a 169 KB `facts.yml` sliced to 96 KB on its way into a design turn that then died. So a turn gets two hours (`timeout_s` 900 → 7200), a prompt 400 KB and inputs 256 KB, a phase ceiling holds every attempt its stages may take so the first retry of a stage that spent anything is no longer refused by arithmetic — `warn_at_pct` still measured against one attempt's share, so the warning still arrives before the money — and the four numbers that were calibrations rather than invariants (`attempts`, `fixlist_rounds`, `reviewer_share`, `gate_signer_share`) became optional `stage.yml` keys, refused by name out of range instead of clamped, absent meaning today's constant byte for byte, with `tldrx run auto --prompt-max-bytes` and `--max-reads` for the unattended run that would otherwise need a file edit to get past one refusal; a story's Definition of Done now runs with its dependencies installed — the `install:` slot has sat unread in `templates/workspace.yml` since the beginning and now runs in every fresh story worktree through the same allowlist-and-argv runner, recorded with its own exit code and duration, blocking the story rather than paying a turn to discover it — an exit 127 is reported as a named absent binary and not as a red test, a declared command may be run WITH ARGUMENTS (the exact `Bash(npm run test)` grant matched nothing the developer actually typed, so its own 127 was first seen by the gate, after the turn was paid for), and every DoD check says which tree it ran in; the Build gate now names story outcomes on every policy and not only `auto` — two runs approved from a phone printed `run is done` over zero stories delivered — `run.yml` records an additive `outcome:` written once by all three commands that close a run and rendered by six surfaces, and `tldrx ship` refuses with exit 1 instead of opening a PR over nothing; a red DoD keeps its real failure — the last 200 lines on disk (gitignored, since a tail can carry a secret), up to five failure-looking lines as the detail rather than the last `DeprecationWarning` on stderr, the failing line cited at the line it starts on, and the next attempt told it was the check and not a reviewer; a watcher card may honestly say `Query: none — <reason> [src: …]`, earned only over a card whose own `## Signal` cites `absent:` and refused like any unsourced item otherwise, after a stage spent real money writing the honest answer and was refused for it; a truncated input is told to the OWNER at spawn and not only to the sub-agent, a turn killed on timeout keeps the usage it had already streamed and never a price; and the maintain skill says which sha a review record must cite — the code head — a rule that cost a wave and was written down nowhere an agent reads |
320
321
  | 0.14.3 | 2026-09-10 | `beta` | foreign uncommitted work no longer stops a Build, and the dashboard flake that blocked four merges in two days has a root cause: the dirty-tree guard used to count every `git status --porcelain` entry and refuse, offering only "commit it" or "stash it" — neither of which an agent may take with another person's files — and measured across three real workspaces on 0.14.2, every first engine-driven run reaching Build stopped at `04-build`, over seed docs, a data export and one untracked note; the dirt is now classified, `own` and `overlapping` refusing or passing exactly as before while everything `foreign` is set aside with a pathspec-limited `git stash push` as the LAST step before the epic branch is cut, recorded as `worktree.foreign_work_aside` and given back with `--index` on every exit path, success or failure, nothing ever deleted and nothing force-popped, a repo mid-merge, rebase, cherry-pick or bisect refused outright because that state has no clean undo, and a pop git refuses said as the stage's last line and carried into the handoff and the notification; the refusal's printed remedy is now the SAME string the engine runs, limited to the paths it listed and relaunching by mode, after an owner ran the pathspec-less line exactly as printed and it swept the run's own records under `tldrx-work/<run>/` into the stash until `tldrx next` answered `no run`; every path handed to git for a write is `:(literal)` and `git status` is read with `-z`, since a glob pathspec moved the neighbouring `x.txt` for a file called `[x].txt`; and the dashboard's live tests stop racing a typed millisecond — five consecutive runs of the two files went red 3 times, at 5084.27 / 5108.01 / 5256.49 ms against a hard-coded 5000 under load averages 65–107 on 14 cores — every deadline now deriving from one `eventWaitMs()` helper that scales like every other budget, with `test/machine-load.test.ts` refusing a hard-coded deadline in either file so it cannot come back at somebody's merge, while that measurement surfaced the product half: `watchWorkspace` armed its mtime sweep only in `poll` mode, so a dropped FSEvents notification left a live dashboard silently stale for the life of the process — measured with `fseventsd` at 98–115% CPU, directory events that never arrived AT ALL at 82,556 ms and 113,942 ms — and the sweep now runs in watch mode too, at 2 s, so a dropped notification is bounded rather than fatal |
321
322
  | 0.14.2 | 2026-09-09 | `beta` | every citation check starts from a fresh view of `questions.md` and `facts.yml`: `srcToken.ts` memoised both indexes at module scope and nothing outside `test/` ever dropped them, so in `tldrx run auto` — one Node process for a whole run — the FIRST citation resolved anywhere froze the view every later stage was then judged against; measured on three real unattended workspaces at 0.14.1, a `how` stage refused with `no such question Q2 … declared: Q1` over questions it had itself written minutes earlier, and another refused over `145 live fact(s)` when `facts.yml` held 148, the three extra written by the owner's answers two seconds before the stage started — roughly $11 of paid turns thrown away for ids that were real the whole time; the indexes are now refreshed inside `toSrcContext`, the one place a citation context is built and a place every caller reaches exactly once per check, gate or hook, so a document's forty citations still read `facts.yml` once while nothing survives the check that read it |
@@ -507,6 +507,16 @@ function statusWithOutcome(status, outcome) {
507
507
 
508
508
  // src/core/run/autoGate.ts
509
509
  var AUTO_GATE_ACTOR = "auto";
510
+ var AUTO_GATE_REFUSED_PREFIX = "auto-gate refused — held by: ";
511
+ var HELD_SEPARATOR = " · ";
512
+ function heldByNote(note) {
513
+ if (!note.startsWith(AUTO_GATE_REFUSED_PREFIX))
514
+ return [];
515
+ const rest = note.slice(AUTO_GATE_REFUSED_PREFIX.length);
516
+ const end = rest.indexOf(HELD_SEPARATOR);
517
+ const ids = (end === -1 ? rest : rest.slice(0, end)).split(", ").map((id) => id.trim());
518
+ return ids.filter((id) => id !== "");
519
+ }
510
520
 
511
521
  // src/core/run/gateAuthority.ts
512
522
  var UNRECORDED_AUTHORIZER = "unrecorded";
@@ -729,12 +739,14 @@ function waitingFor(run, runDir) {
729
739
  }
730
740
  const open = blockingQuestionIds(join6(runDir, cursor.phase, "questions.md"));
731
741
  switch (entry.stage.status) {
732
- case "awaiting_gate":
742
+ case "awaiting_gate": {
743
+ const held = heldByNote(entry.stage.gate?.note ?? "");
733
744
  return {
734
745
  kind: "gate",
735
- message: `gate on ${entry.phase.id}/${entry.stage.id} — \`tldrx approve\` or \`tldrx reject --note "…"\``,
746
+ message: `gate on ${entry.phase.id}/${entry.stage.id}` + (held.length === 0 ? "" : ` held by ${held.join(", ")}`) + ` — \`tldrx approve\` or \`tldrx reject --note "…"\``,
736
747
  questions: open
737
748
  };
749
+ }
738
750
  case "awaiting_answer":
739
751
  return {
740
752
  kind: "answer",
@@ -20,7 +20,7 @@ import {
20
20
  runSnapshot,
21
21
  statusWithOutcome,
22
22
  whatIsWaiting
23
- } from "./chunk-zccma7kt.js";
23
+ } from "./chunk-vvr4rk82.js";
24
24
  import {
25
25
  expertsDir,
26
26
  loadExperts,
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  bar,
4
4
  runSnapshot
5
- } from "./chunk-zccma7kt.js";
5
+ } from "./chunk-vvr4rk82.js";
6
6
  import"./chunk-3kmx3dmz.js";
7
7
  import"./chunk-rz0qr006.js";
8
8
  import"./chunk-d0rp8c68.js";
package/dist/tldrx.js CHANGED
@@ -15643,6 +15643,12 @@ var ENTRIES = [
15643
15643
  meaning: "Send the workspace's declared notify hook a `status` payload this often while the loop runs — `30s`, `10m`, `2h`, or a bare number of seconds. Off by default, and it does nothing at all unless `.tldrx/workspace.yml` declares a `notify:` command (§2.18). A `status` payload carries what `tldrx run status` prints. It asks for nothing while the run is moving — and when the run is PARKED on an open question it says so and repeats the literal answer command, because a heartbeat that keeps saying nothing is waiting on you while a run waits on you is worse than silence.",
15644
15644
  sub: "auto"
15645
15645
  },
15646
+ {
15647
+ name: "retry-failed",
15648
+ arg: "<n>",
15649
+ meaning: 'How many times in a row the loop may run a FAILED stage again before it stops. 0 — the default, and what every invocation before this got — means one attempt and then exit 5. A retry is the same `tldrx next` a person would have typed: the stage is on disk as `failed` with its reason recorded, and the next attempt is told what the last one did. It bounds EXIT 5 AND NOTHING ELSE — a usage error (1), a money refusal (2) and an awaiting-human park (4) are attempted once however large the bound, because each is a decision a person owns; a phase ceiling especially, which means "a human decides about money" and would otherwise become a delay. Only CONSECUTIVE failures count: a stage that succeeds puts the count back to zero. A retry SPENDS — it is a fresh metered stage under the same phase ceiling and the same --max-usd — and when the bound is spent the loop stops on the failure\'s own exit 5, with the count in the last line.',
15650
+ sub: "auto"
15651
+ },
15646
15652
  {
15647
15653
  name: "wait-gates",
15648
15654
  arg: "<duration>",
@@ -15695,6 +15701,7 @@ var ENTRIES = [
15695
15701
  "tldrx run auto --parallel 3",
15696
15702
  "tldrx run auto --prompt-max-bytes 500000 --max-reads 300",
15697
15703
  "tldrx run auto --notify-every 10m",
15704
+ "tldrx run auto --retry-failed 2",
15698
15705
  "tldrx run auto --wait-answers 30m",
15699
15706
  "tldrx run auto --wait-answers 4h --wait-gates 4h",
15700
15707
  "tldrx run unlock 260101-checkout --force",
@@ -15703,7 +15710,7 @@ var ENTRIES = [
15703
15710
  exits: [EXIT_OK, EXIT_USAGE, EXIT_GATE_REFUSED, EXIT_NOT_FOUND, EXIT_AWAITING_HUMAN, EXIT_AGENT_FAILED],
15704
15711
  notes: [
15705
15712
  "`run attend host` is a LOCK, not an engine. It sets one field, spends nothing, runs no stage and touches no branch — and from then on THE FRAMEWORK WILL NOT SPAWN on that run: every turn is a `tldrx next --prepare` / `tldrx next --commit` handshake with the session driving it, the Build reviewer included. `run attend --none` hands it back.",
15706
- "`run auto` is an ENGINE, not a lock. It calls `next` HEADLESS over and over, so THE FRAMEWORK spawns a metered sub-agent stage after stage, and it stops at the first thing it may not decide: a human gate or an open question (4), a stage failure (5), a phase ceiling or this loop's own --max-usd (2). It is REFUSED ON AN ATTENDED RUN (exit 1, before the event log is opened) — a lock and an engine are alternatives, never layers.",
15713
+ "`run auto` is an ENGINE, not a lock. It calls `next` HEADLESS over and over, so THE FRAMEWORK spawns a metered sub-agent stage after stage, and it stops at the first thing it may not decide: a human gate or an open question (4), a stage failure (5) — unless `--retry-failed <n>` lets it run that stage again, bounded, up to n times in a row — a phase ceiling or this loop's own --max-usd (2). It is REFUSED ON AN ATTENDED RUN (exit 1, before the event log is opened) — a lock and an engine are alternatives, never layers.",
15707
15714
  "`run auto` can also TELL SOMEBODY. When `.tldrx/workspace.yml` declares a `notify:` command (§2.18), the loop hands that command one `version: 1` JSON object on stdin at every moment a person is needed — an open question with its options and the literal `tldrx answer` line, a gate with the literal approve line, a finished or failed run with its exit code and family — plus a periodic `status` under `--notify-every`. The framework names no chat tool: the command is the owner's own, run as argv with no shell, and its exit code is recorded as `notify.sent` / `notify.failed` and NEVER changes the run's outcome.",
15708
15715
  "Under `run auto`, a stage whose `gates_policy` is `agent` gets one bounded GATE-SIGNER turn of its own. When the stage's checks have passed, the engine spawns a single sub-agent at the stage's model and effort, on a quarter of the stage's per-agent ceiling, allowed to read and to write exactly one file: `.agent/<stage>/evidence.md`. The note then goes through the UNCHANGED `approve --as-agent` path — the same validator a person's note goes through — so `verdict: sign` plus every condition holding closes the gate under the note's own `by:`, and anything else leaves it pending for a person with the reasons named. The turn is recorded like any other (`agent.spawned` / `agent.result`, `role: gate-signer`) and shows up in `tldrx cost`. There is no flag for it: `gates_policy: agent` is already the owner's recorded decision that an agent may close this gate, and `human` gates are never touched.",
15709
15716
  "`run status` with several runs open LISTS them and exits 0 — it is the screen you read to find the id every other command wants.",
@@ -29357,6 +29364,22 @@ async function evaluateAutoGate(input) {
29357
29364
  function heldBy(verdict) {
29358
29365
  return verdict.conditions.filter((condition) => !condition.ok).map((condition) => condition.id);
29359
29366
  }
29367
+ var AUTO_GATE_REFUSED_PREFIX = "auto-gate refused — held by: ";
29368
+ var HELD_SEPARATOR = " · ";
29369
+ function refusalNote(verdict) {
29370
+ const held2 = heldBy(verdict);
29371
+ if (held2.length === 0)
29372
+ return "";
29373
+ return `${AUTO_GATE_REFUSED_PREFIX}${held2.join(", ")}${HELD_SEPARATOR}` + verdict.conditions.map(render2).join("; ");
29374
+ }
29375
+ function heldByNote(note) {
29376
+ if (!note.startsWith(AUTO_GATE_REFUSED_PREFIX))
29377
+ return [];
29378
+ const rest = note.slice(AUTO_GATE_REFUSED_PREFIX.length);
29379
+ const end = rest.indexOf(HELD_SEPARATOR);
29380
+ const ids = (end === -1 ? rest : rest.slice(0, end)).split(", ").map((id) => id.trim());
29381
+ return ids.filter((id) => id !== "");
29382
+ }
29360
29383
  async function reevaluateAutoGate(input) {
29361
29384
  const phase = input.run.phases.find((entry) => entry.stages.some((stage3) => stage3.id === input.stageId));
29362
29385
  const stage2 = phase?.stages.find((entry) => entry.id === input.stageId);
@@ -29377,7 +29400,7 @@ async function reevaluateAutoGate(input) {
29377
29400
  runDir: input.runDir,
29378
29401
  stage: planned
29379
29402
  });
29380
- return await evaluateAutoGate({
29403
+ const verdict = await evaluateAutoGate({
29381
29404
  root: input.root,
29382
29405
  runDir: input.runDir,
29383
29406
  phaseId: phase.id,
@@ -29386,6 +29409,30 @@ async function reevaluateAutoGate(input) {
29386
29409
  budget: input.budget,
29387
29410
  checks
29388
29411
  });
29412
+ if (!verdict.ok)
29413
+ recordRefusal(input.runDir, input.stageId, verdict);
29414
+ return verdict;
29415
+ }
29416
+ function recordRefusal(runDir, stageId, verdict) {
29417
+ const note = refusalNote(verdict);
29418
+ if (note === "")
29419
+ return;
29420
+ try {
29421
+ withWorkspaceLock(workspaceRootOfRunDir(runDir), () => {
29422
+ const store = RunStore.open(runDir);
29423
+ const current = store.run.phases.flatMap((phase) => phase.stages).find((stage2) => stage2.id === stageId);
29424
+ if (current === undefined || current.gate.status !== "pending" || current.gate.note === note)
29425
+ return;
29426
+ store.mutate((run) => ({
29427
+ ...run,
29428
+ phases: run.phases.map((phase) => ({
29429
+ ...phase,
29430
+ stages: phase.stages.map((stage2) => stage2.id === stageId && stage2.gate.status === "pending" ? { ...stage2, gate: { ...stage2.gate, note } } : stage2)
29431
+ }))
29432
+ }));
29433
+ store.save();
29434
+ });
29435
+ } catch {}
29389
29436
  }
29390
29437
  function render2(condition) {
29391
29438
  return `${condition.id}=${condition.detail}`;
@@ -29708,12 +29755,14 @@ function waitingFor(run, runDir) {
29708
29755
  }
29709
29756
  const open = blockingQuestionIds(join66(runDir, cursor.phase, "questions.md"));
29710
29757
  switch (entry.stage.status) {
29711
- case "awaiting_gate":
29758
+ case "awaiting_gate": {
29759
+ const held2 = heldByNote(entry.stage.gate?.note ?? "");
29712
29760
  return {
29713
29761
  kind: "gate",
29714
- message: `gate on ${entry.phase.id}/${entry.stage.id} — \`tldrx approve\` or \`tldrx reject --note "…"\``,
29762
+ message: `gate on ${entry.phase.id}/${entry.stage.id}` + (held2.length === 0 ? "" : ` held by ${held2.join(", ")}`) + ` — \`tldrx approve\` or \`tldrx reject --note "…"\``,
29715
29763
  questions: open
29716
29764
  };
29765
+ }
29717
29766
  case "awaiting_answer":
29718
29767
  return {
29719
29768
  kind: "answer",
@@ -30020,7 +30069,7 @@ function renderGates(rows, verbose = false) {
30020
30069
  const auto = rows.filter((row2) => row2.policy === "auto").length;
30021
30070
  const agent = rows.filter((row2) => row2.policy === "agent").length;
30022
30071
  const human = rows.length - auto - agent;
30023
- const noted2 = rows.filter((row2) => row2.note !== null).length;
30072
+ const noted2 = rows.filter((row2) => isSigned(row2) && row2.note !== null).length;
30024
30073
  const lines = [
30025
30074
  `gates ${String(human)} human, ${String(auto)} auto` + (agent === 0 ? "" : `, ${String(agent)} agent`)
30026
30075
  ];
@@ -30043,10 +30092,13 @@ function renderGates(rows, verbose = false) {
30043
30092
  var NOTE_MARK = "✎";
30044
30093
  function gateTail(row2) {
30045
30094
  const parts = [briefDuration(row2.started_at, row2.ended_at)];
30046
- if (row2.note !== null)
30095
+ if (isSigned(row2) && row2.note !== null)
30047
30096
  parts.push(NOTE_MARK);
30048
30097
  return parts.filter((part) => part !== "").join(" ");
30049
30098
  }
30099
+ function isSigned(row2) {
30100
+ return row2.status === "approved" || row2.status === "rejected";
30101
+ }
30050
30102
  function briefDuration(startedAt, endedAt) {
30051
30103
  const measured = dashDuration(startedAt, endedAt);
30052
30104
  if (measured !== "")
@@ -30076,7 +30128,8 @@ function describeGate(row2) {
30076
30128
  return `rejected by ${describeGateSignature(row2)}`;
30077
30129
  if (row2.status === "n-a")
30078
30130
  return `${row2.type}: n-a`;
30079
- return `${row2.type}: ${row2.status}`;
30131
+ const held2 = heldByNote(row2.note ?? "");
30132
+ return `${row2.type}: ${row2.status}` + (held2.length === 0 ? "" : ` — held by ${held2.join(", ")}`);
30080
30133
  }
30081
30134
  var NOTES_SHOWN = 3;
30082
30135
  function renderOperatorNotes(notes) {
@@ -40210,6 +40263,8 @@ var EXIT_USAGE4 = 1;
40210
40263
  var EXIT_REFUSED3 = 2;
40211
40264
  var EXIT_NOT_FOUND4 = 3;
40212
40265
  var EXIT_AWAITING_HUMAN3 = 4;
40266
+ var EXIT_AGENT_FAILED3 = 5;
40267
+ var MAX_RETRY_FAILED = 3;
40213
40268
  var MAX_ITERATIONS = 96;
40214
40269
  async function runAuto(options) {
40215
40270
  const lines = [];
@@ -40349,6 +40404,7 @@ async function runAuto(options) {
40349
40404
  }
40350
40405
  return { costUsd: null, deferredGate: null };
40351
40406
  };
40407
+ let consecutiveFailures = 0;
40352
40408
  try {
40353
40409
  for (let iteration = 0;iteration < MAX_ITERATIONS; iteration++) {
40354
40410
  const store = RunStore.open(runDir2);
@@ -40400,7 +40456,19 @@ async function runAuto(options) {
40400
40456
  if (send !== null)
40401
40457
  await send();
40402
40458
  };
40459
+ if (outcome.code !== EXIT_AGENT_FAILED3)
40460
+ consecutiveFailures = 0;
40403
40461
  if (outcome.code !== EXIT_OK5) {
40462
+ const retryBound = options.retryFailedStages ?? 0;
40463
+ if (outcome.code === EXIT_AGENT_FAILED3) {
40464
+ consecutiveFailures += 1;
40465
+ if (consecutiveFailures <= retryBound) {
40466
+ for (const line of outcome.lines)
40467
+ say(` ${line}`);
40468
+ say(`retrying ${cursorBefore} — failure ${String(consecutiveFailures)} of ` + `${String(retryBound)} retries allowed (--retry-failed ${String(retryBound)})`);
40469
+ continue;
40470
+ }
40471
+ }
40404
40472
  let rejection = null;
40405
40473
  if (outcome.code === EXIT_AWAITING_HUMAN3) {
40406
40474
  const card = openQuestions2();
@@ -40447,6 +40515,9 @@ async function runAuto(options) {
40447
40515
  say(line);
40448
40516
  if (rejection !== null)
40449
40517
  say(rejection);
40518
+ if (outcome.code === EXIT_AGENT_FAILED3 && retryBound > 0) {
40519
+ say(`stopped: ${String(consecutiveFailures)} consecutive stage failures at ${cursorBefore} ` + `— the --retry-failed ${String(retryBound)} bound is spent, and the exit code is still ` + "5, the failure's own");
40520
+ }
40450
40521
  return await finish(outcome.code, spentByLoop);
40451
40522
  }
40452
40523
  const after = RunStore.open(runDir2);
@@ -42691,7 +42762,8 @@ var VALUE_FLAGS = [
42691
42762
  "wait-answers",
42692
42763
  "wait-gates",
42693
42764
  "prompt-max-bytes",
42694
- "max-reads"
42765
+ "max-reads",
42766
+ "retry-failed"
42695
42767
  ];
42696
42768
  function durationFlag(args, name) {
42697
42769
  const raw = stringFlag(args, name);
@@ -42703,6 +42775,15 @@ function durationFlag(args, name) {
42703
42775
  }
42704
42776
  return ms;
42705
42777
  }
42778
+ function retryFailedFlag(args) {
42779
+ const value = numberFlag(args, "retry-failed");
42780
+ if (value === undefined)
42781
+ return;
42782
+ if (!Number.isInteger(value) || value < 0 || value > MAX_RETRY_FAILED) {
42783
+ throw new UsageError(`--retry-failed must be a whole number between 0 and ${String(MAX_RETRY_FAILED)} ` + "(0 is the default: a failed stage stops the loop). A stage that has failed that many " + "times in a row is failing for a reason another spawn will not discover, and every " + "attempt is real money.");
42784
+ }
42785
+ return value;
42786
+ }
42706
42787
  var runCommand = {
42707
42788
  name: "run",
42708
42789
  summary: "Create, inspect or auto-run a piece of work",
@@ -42715,6 +42796,7 @@ var runCommand = {
42715
42796
  ` + ` tldrx run auto [<run>] [--max-usd <n>] [--until <stage>] [--model <m>] [--effort <level>]
42716
42797
  ` + ` [--notify-every <duration>] [--wait-answers <duration>]
42717
42798
  ` + ` [--wait-gates <duration>] [--prompt-max-bytes <n>] [--max-reads <n>]
42799
+ ` + ` [--retry-failed <n>]
42718
42800
  ` + ` [--yolo] [--parallel <n>] [--gate-agent] [--ui scene|compact|plain|off]
42719
42801
  ` + ` [--run <id>] [--root <path>]
42720
42802
  ` + ` tldrx run gates set <stage>:<human|auto|agent> --note <text> [--run <id>] [--root <path>]
@@ -42877,6 +42959,7 @@ async function runAutoLoop(argv) {
42877
42959
  notifyEveryMs: durationFlag(args, "notify-every"),
42878
42960
  waitAnswersMs: durationFlag(args, "wait-answers"),
42879
42961
  waitGatesMs: durationFlag(args, "wait-gates"),
42962
+ retryFailedStages: retryFailedFlag(args),
42880
42963
  actor: currentActor(),
42881
42964
  at: nowRfc3339(),
42882
42965
  onLine: (line) => ui2.log(() => process.stdout.write(`${line}
@@ -43294,7 +43377,7 @@ var PROPOSE_STAGE = "propose";
43294
43377
  var EXIT_OK9 = 0;
43295
43378
  var EXIT_USAGE6 = 1;
43296
43379
  var EXIT_REFUSED7 = 2;
43297
- var EXIT_AGENT_FAILED3 = 5;
43380
+ var EXIT_AGENT_FAILED4 = 5;
43298
43381
  var DEFAULT_TRIAGE_EFFORT = "low";
43299
43382
  var DEFAULT_TRIAGE_USD = 1;
43300
43383
  var MIN_TRIAGE_USD = 0.25;
@@ -43428,7 +43511,7 @@ async function propose(options, workspaceCommands, inventory, seed, outDir) {
43428
43511
  metered = outcome.metered;
43429
43512
  if (!outcome.ok) {
43430
43513
  return {
43431
- code: EXIT_AGENT_FAILED3,
43514
+ code: EXIT_AGENT_FAILED4,
43432
43515
  costUsd,
43433
43516
  outDir,
43434
43517
  inventory,
@@ -43449,7 +43532,7 @@ async function propose(options, workspaceCommands, inventory, seed, outDir) {
43449
43532
  if (mode === "commit")
43450
43533
  writeRaw(outDir, PROPOSE_STAGE, JSON.stringify(raw ?? null, null, 2));
43451
43534
  return {
43452
- code: EXIT_AGENT_FAILED3,
43535
+ code: EXIT_AGENT_FAILED4,
43453
43536
  costUsd,
43454
43537
  outDir,
43455
43538
  inventory,
@@ -48535,7 +48618,7 @@ var EXIT_OK14 = 0;
48535
48618
  var EXIT_USAGE9 = 1;
48536
48619
  var EXIT_GATE_REFUSED2 = 2;
48537
48620
  var EXIT_NOT_FOUND12 = 3;
48538
- var EXIT_AGENT_FAILED4 = 5;
48621
+ var EXIT_AGENT_FAILED5 = 5;
48539
48622
  var TRAIN_TIMEOUT_MS = 30 * 60 * 1000;
48540
48623
  async function runTraining(options) {
48541
48624
  const said2 = [];
@@ -48775,7 +48858,7 @@ async function trainWithPreflight(options, said2) {
48775
48858
  }));
48776
48859
  if (!outcome.ok) {
48777
48860
  rollback(previous);
48778
- return fail3(EXIT_AGENT_FAILED4, [
48861
+ return fail3(EXIT_AGENT_FAILED5, [
48779
48862
  `${options.expert}/${area.id}: the ${task2.key} sub-agent failed — ${outcome.error ?? "no result"}`,
48780
48863
  outcome.metered ? ` $${round29(outcome.costUsd).toFixed(2)} spent and recorded; nothing was written to competencies.yml` : ` ${providerWarning ?? "the provider turn was unmetered in dollars"}; nothing was written to competencies.yml`
48781
48864
  ], sum(tasks));
@@ -49114,7 +49197,7 @@ function reject2(log, options, area, costUsd, lines, detail) {
49114
49197
  ...fitProblems(detail.problems)
49115
49198
  }
49116
49199
  }));
49117
- return { code: EXIT_AGENT_FAILED4, lines, costUsd };
49200
+ return { code: EXIT_AGENT_FAILED5, lines, costUsd };
49118
49201
  }
49119
49202
  function fitProblems(problems) {
49120
49203
  const budget = MAX_PAYLOAD_BYTES2 - 1024;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tldr-experts",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "tldr-experts: an evidence-first, file-based AI development framework - five stages, a gate on every one, and every claim cited or refused. Installs the `tldrx` (and `tldr-experts`) command. Beta.",
5
5
  "license": "MIT",
6
6
  "author": "Alan Martinez",
@@ -2,7 +2,7 @@
2
2
  "$doc": "Shape verified from https://code.claude.com/docs/en/plugins.md (Quickstart > Create the plugin manifest). Fields used here: name, description, version, author.name. Only plugin.json goes inside .claude-plugin/; skills/, agents/ and hooks/ live at the plugin root.",
3
3
  "name": "tldrx",
4
4
  "description": "tldr-experts: an evidence-first, file-based AI development framework. Five stages, a gate on every one, every claim cited or refused. Beta.",
5
- "version": "0.15.0",
5
+ "version": "0.16.0",
6
6
  "author": {
7
7
  "name": "Alan Martinez"
8
8
  }