tldr-experts 0.16.0 → 0.16.1

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,116 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.1 — 2026-09-12
4
+
5
+ ### Added
6
+
7
+ - **`tldrx reject --and-continue` — a rejection that means "redo it this way and carry on"
8
+ (#242).** `run auto --wait-gates` resumed after an approve and STOPPED after a reject, so on a
9
+ phone the button meaning *there is still work to do* was the one that ended the run: the only
10
+ way to act on a rejection was to walk to a terminal and relaunch. The stop was deliberate and
11
+ its reasoning is real — resuming re-spends the stage on a decision the person who rejected it
12
+ has not been shown the result of — but it is the reasoning for ONE kind of rejection, "stop, I
13
+ will look", and the issue measured five consecutive live rejections that all meant the other
14
+ one: *"faltan 4 stories sin arrancar; continuar el build"*, *"Rehacer S2 y las waves 3 y 4"*,
15
+ *"Una ronda mas"*. Five rejections, five manual relaunches, the assumption holding zero times
16
+ out of five. So the rejection now SAYS which act it is instead of the loop guessing from the
17
+ note's words or from what was holding the gate: `--and-continue` records `and_continue: true`
18
+ on the gate record `tldrx reject` writes — the same object `--wait-gates` already reads the
19
+ gate's `status` off, in the same read, so there is no second derivation and no second process
20
+ to ask — and the loop re-runs the stage with the note, exactly as the manual relaunch did. A
21
+ bare `tldrx reject` is byte-identical to what it wrote before and stops the loop exactly as
22
+ before; `and_continue` is additive and only ever `true`, so a gate written before this key
23
+ existed reads as "stop". It is cleared when the stage parks on its gate again, so it never
24
+ outlives the rejection it describes. The rationale in `runAuto.ts` now names both kinds and
25
+ says which one is the default. Which BUTTON a notification offers for which effect is
26
+ deliberately not part of this change.
27
+
28
+ ### Fixed
29
+
30
+ - **The `merge-wave` "known flake" was a real race, and it was in the guard's own INSTALL
31
+ (#115).** For months `test/merge-wave.test.ts`'s concurrency cases reddened CI, went green on
32
+ a same-sha re-run, and were waved through under §4's re-run licence — three separate cases in
33
+ one night alone. Nobody had read the failure detail. `gh run view 34671878974 --log-failed`
34
+ (sha `8cd4df2`) says it in two lines: `fatal: cannot exec '.git/hooks/reference-transaction':
35
+ Text file busy` → `update aborted by the reference-transaction hook`. `merge-wave.sh` installs
36
+ the ref guard BEFORE it queues for the lock — deliberately, so an unguarded window never
37
+ exists — so two invocations overlap on exactly one file, and `install_hook` wrote it with
38
+ `cat > "$hook"`: truncate-and-rewrite THE SAME INODE, while the invocation holding the lock
39
+ has a `git merge` exec'ing it. On Linux exec of a file open for write is ETXTBSY; on macOS the
40
+ identical race is benign, which is the whole of why it was green locally and red on CI, and
41
+ why a same-sha re-run failed 2-for-2 rather than passing. The hook is now written to
42
+ `reference-transaction.tmp.$$`, made executable there, and `mv -f`'d into place — the exec'd
43
+ inode is never the written inode, and no reader can catch a zero-length window. It is the
44
+ answer `$MARKER` in `merge-wave.sh` already used, and the trap AGENTS.md §12 already named.
45
+ The race is untestable on macOS by design, so the WRITE is what is pinned: a reinstall must
46
+ land on a new inode and must leave the inode a hard link is holding byte-identical — red on
47
+ both counts before the fix. And the refusal that hid all this now tells the truth: a merge the
48
+ ref-transaction hook aborted, with no conflicting path anywhere, says so and exits **11**, its
49
+ own code, instead of borrowing `2` and telling the agent to go rebase against a conflict that
50
+ never existed. A mislabelled refusal is how a deterministic defect becomes folklore; §4's
51
+ re-run licence for this test is withdrawn in the same change.
52
+ - **The views fixture no longer decays: `test/experts.test.ts` was a wall-clock time bomb
53
+ (#240).** `main` went red at `e1d284d` — the exact sha of published 0.16.0, with no commit in
54
+ between — because `competencyLevel` weighs every evidence row by its AGE and the fixture dated
55
+ its rows ABSOLUTELY (`at: 2026-08-20`, …). An in-process test hands the reader `VIEWS_NOW` and
56
+ is hermetic; a test that spawns the CLI cannot, because the CLI reads `new Date()`. So
57
+ `dotnet-stack/ef-core` sat 3% above the level-3 threshold on the day the assertion was written
58
+ and fell through it eleven days later, by the calendar alone. Bumping the expected number would
59
+ only have re-armed the bomb for a later date, so the FIXTURE moved instead:
60
+ `makeViewsWorkspace({ now })` re-dates the copied evidence so every row keeps the age the
61
+ fixture meant, relative to the clock the assertion is evaluated against, and `NOW` in
62
+ `experts.test.ts` is the real clock rather than a pinned calendar day. Measured with the clock
63
+ moved a year forward: reverted, five tests red (the one that reds today plus four with longer
64
+ fuses); fixed, the whole suite is green at +1 year and the fixture's own consumers are green at
65
+ +5. §8's hermeticity rule now covers the clock as well as `$TMPDIR`.
66
+ - **A parked gate hands over the command that CLEARS it, not `tldrx approve` whatever is
67
+ holding it (#239).** Measured on an owner's phone, 2026-09-10: a gate held BY five
68
+ unanswered questions was announced as `Run: tldrx approve --run <id>`, and the
69
+ `--notify-every` heartbeat repeated that same line seven times in an hour. The sentence
70
+ named the holding condition correctly — #203 was working — and the ACTION contradicted it,
71
+ which is the worse half: a notification exists to be obeyed off a lock screen. Two Build
72
+ gates were approved by mistake that evening, both over unbuilt stories, both revoked with
73
+ `reject --stage`; the owner said so himself — *"no sé por qué me avisa que ya puedo cerrarlo
74
+ si aún hay preguntas"*. `command` now follows the holding condition, in ONE mapping shared by
75
+ `gate.requested` and the parked heartbeat — and over ONE reading of the pending gate,
76
+ `gateStories`, so the alert and its reminder can never offer two different taps: open blocking questions → the `tldrx answer` line (the gate is downstream of
77
+ them); unfinished stories → `tldrx run status` (not `reject` — nobody has decided to abandon
78
+ that work, and a one-tap refusal is the mirror of the mistake being fixed — and not `null`,
79
+ because what is missing there is knowledge, not a signature); nothing mechanical outstanding
80
+ → `tldrx approve`, which is what the field always meant. `approve_command` and
81
+ `reject_command` stay in the `detail` of every payload, so an adapter that renders buttons
82
+ keeps both. The open question ids are read off disk by the loop through `blockingQuestionIds`
83
+ — the one predicate `--wait-answers` polls — at the moment the notification is SENT, so a
84
+ gate whose questions cleared while the send was deferred does not point at an answered
85
+ question; the heartbeat reads the gate's stories the same way, every tick, instead of the
86
+ hard-wired "nothing unfinished" that kept it saying `approve` at a Build gate held by unbuilt
87
+ work — the gate the two mistaken approvals were on.
88
+ - **The Build gate's summary has a verb again (#239).** `deliveredPhrase` is a noun phrase and
89
+ three callers embed it after a label, so `It 5 of 6 stories delivered, S6 blocked (…)` was
90
+ reaching lock screens as a typo. The article is fixed at the one call site that needed a
91
+ sentence — `It has 5 of 6 stories delivered` — and the phrase's contract is unchanged for
92
+ `run next`, `ship` and the decision card.
93
+
94
+ - **A red base pre-flight now keeps what the command SAID, so a stage-wide refusal names a
95
+ cause (#229).** #211 taught a red story DoD to keep its output — the file on disk, an
96
+ excerpt, the failing line, a failure-shaped `tail` — and the base row, produced a hundred
97
+ lines away in the same file, was left on `outcome.tail`: the last line of stdout+stderr.
98
+ The blast radius and the evidence were the wrong way round. A red story DoD blocks ONE
99
+ story; a red base refuses the WHOLE stage before anything is dispatched or charged.
100
+ Measured in the field: a `dotnet test` whose 163,702 captured lines named a dead container
101
+ daemon on line 12 refused all six stories of a stage with `Test run completed with
102
+ non-success exit code: 2` — the sentence every failing run of that runner prints, whatever
103
+ broke. Nothing in the run directory contained the word `Docker`, so diagnosing the refusal
104
+ meant re-running by hand the command the pre-flight exists to have already run. The base
105
+ row now goes through the SAME seam, not a second reading of it: `tail` is the
106
+ failure-looking line, `excerpt` the few lines around it, and the whole bounded tail is
107
+ written to `04-build/log/dod-output/base-<hash>-1.txt`, which the refusal cites by file and
108
+ line. A GREEN base still writes nothing — #211's argument holds harder here, since a green
109
+ base is re-used from cache far more often than a story's — and an `unmeasured` row is
110
+ untouched: nothing ran, it refuses nothing, and its `tail` is already a reason sentence.
111
+ `04-build/preflight.yml` stays `version: 1`: four optional fields, and every older file
112
+ still reads.
113
+
3
114
  ## 0.16.0 — 2026-09-11
4
115
 
5
116
  ### 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.1 | 2026-09-12 | `beta` | five things the framework knew and did not say, or said wrong — four of them found by using it rather than by reading it: a red base pre-flight now KEEPS its output, so a refusal that blocks every story in a Build names the failing test and cites the file, where it used to record only the last line of stdout — measured 2026-09-10, a stage refused with `tail: "Test run completed with non-success exit code: 2"` while the cause, `DockerUnavailableException`, sat on line 12 of 163,702 lines the run had already captured and thrown away, so diagnosing a refusal the framework had itself measured meant re-running the workspace's test command by hand; it now routes through the same seam #211 built for a story's DoD, which had been naming its failing test correctly all along on the same command, the same day, in the same repo — the path with the SMALLER blast radius was the legible one; a gate notification now offers the command that CLEARS it rather than always `tldrx approve` — questions open give `tldrx answer <id>`, unfinished stories give `tldrx run status`, and `approve` is offered only when nothing mechanical is outstanding, after an owner approved a Build gate by mistake twice in one evening over unbuilt stories, each time from a phone, each time needing a revoke, while a ten-minute heartbeat repeated `Run: tldrx approve` seven times under a sentence that correctly named the five open questions holding it; `tldrx reject --and-continue` lets a rejection mean "redo it this way and carry on" instead of ending the run — the loop resumed after an approve and stopped after a reject, so the button meaning "there is still work to do" was the one that stopped the work and only a terminal could revive it; five real rejections that night all meant continue, five cost a manual relaunch, and a bare `tldrx reject` still writes a byte-identical `run.yml` and stops exactly as before; the expert-recompute fixture anchors its evidence dates to a `now` it can move, so `bun test` stops going red by the calendar — pristine `main` was red at the exact sha of the published 0.16.0 with no commit in between, and a clock moved one year forward reddened FIVE cases, not the one that had already fired; and `test/merge-wave.test.ts`'s concurrency failure, documented as a known flake since #115 and carrying a written licence to re-run it, was never one: `merge-guard.sh` rewrote `.git/hooks/reference-transaction` IN PLACE while a sibling wave's `git merge` was exec'ing it — ETXTBSY on Linux at 31% under contention, benign on macOS, which is why it was green locally and red in CI, and why a same-sha re-run failed 2 for 2 rather than passing; the hook is now written to a temp file and RENAMED into place, the refusal that used to borrow `2`/`merge conflict` for a hook abort now says what it was and exits 11, and AGENTS.md §4 withdraws the re-run licence for those two cases while naming the interrupted-merge case (#237) as still open and undiagnosed — because "all real" for a whole file costs the same as "all flake", in the other direction |
319
320
  | 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 |
320
321
  | 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 |
321
322
  | 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 |
@@ -99,7 +99,8 @@ function gate(g) {
99
99
  const evidence = g.evidence === undefined ? "" : `, evidence: ${gateEvidence(g.evidence)}`;
100
100
  const executor = g.executed_by === undefined ? "" : `, executed_by: ${gateExecutor(g.executed_by)}`;
101
101
  const authority = g.authority === undefined ? "" : `, authority: ${gateAuthority(g.authority)}`;
102
- return `{type: ${yamlScalar(g.type)}, status: ${yamlScalar(g.status)}, by: ${yamlScalar(g.by)}, ` + `at: ${yamlScalar(g.at)}, note: ${yamlScalar(g.note)}${evidence}${executor}${authority}}`;
102
+ const andContinue = g.and_continue === undefined ? "" : ", and_continue: true";
103
+ return `{type: ${yamlScalar(g.type)}, status: ${yamlScalar(g.status)}, by: ${yamlScalar(g.by)}, ` + `at: ${yamlScalar(g.at)}, note: ${yamlScalar(g.note)}${evidence}${executor}${authority}${andContinue}}`;
103
104
  }
104
105
  function task(t, indent) {
105
106
  const inner = `${indent} `;
@@ -20,7 +20,7 @@ import {
20
20
  runSnapshot,
21
21
  statusWithOutcome,
22
22
  whatIsWaiting
23
- } from "./chunk-vvr4rk82.js";
23
+ } from "./chunk-gv3sj8gv.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-vvr4rk82.js";
5
+ } from "./chunk-gv3sj8gv.js";
6
6
  import"./chunk-3kmx3dmz.js";
7
7
  import"./chunk-rz0qr006.js";
8
8
  import"./chunk-d0rp8c68.js";
package/dist/tldrx.js CHANGED
@@ -11902,7 +11902,8 @@ function gate(g) {
11902
11902
  const evidence = g.evidence === undefined ? "" : `, evidence: ${gateEvidence(g.evidence)}`;
11903
11903
  const executor = g.executed_by === undefined ? "" : `, executed_by: ${gateExecutor(g.executed_by)}`;
11904
11904
  const authority = g.authority === undefined ? "" : `, authority: ${gateAuthority(g.authority)}`;
11905
- return `{type: ${yamlScalar(g.type)}, status: ${yamlScalar(g.status)}, by: ${yamlScalar(g.by)}, ` + `at: ${yamlScalar(g.at)}, note: ${yamlScalar(g.note)}${evidence}${executor}${authority}}`;
11905
+ const andContinue = g.and_continue === undefined ? "" : ", and_continue: true";
11906
+ return `{type: ${yamlScalar(g.type)}, status: ${yamlScalar(g.status)}, by: ${yamlScalar(g.by)}, ` + `at: ${yamlScalar(g.at)}, note: ${yamlScalar(g.note)}${evidence}${executor}${authority}${andContinue}}`;
11906
11907
  }
11907
11908
  function task(t, indent) {
11908
11909
  const inner = `${indent} `;
@@ -15652,7 +15653,7 @@ var ENTRIES = [
15652
15653
  {
15653
15654
  name: "wait-gates",
15654
15655
  arg: "<duration>",
15655
- meaning: "Instead of exiting 4 the moment a stage parks on a pending GATE, poll the run for this long and resume if somebody signs it. `--wait-answers`' sibling for the other half of exit 4: a gate is closed by `tldrx approve` / `tldrx reject`, not by an answer. Approved → the loop carries on; rejected → it stops and prints the note; lapsed → exit 4 with the same lines it always had, after one `gate.timeout` notification. It WAITS FOR a signature and never produces one. A stage on `gates_policy: agent` has already had the engine's own gate signer run on it before this flag ever sees the gate (see the `gates_policy: agent` note below), so what is left to wait for here is a PERSON — the same wait a `human` gate gets. Nothing is spent while it waits. Both wait flags may be given together.",
15656
+ meaning: "Instead of exiting 4 the moment a stage parks on a pending GATE, poll the run for this long and resume if somebody signs it. `--wait-answers`' sibling for the other half of exit 4: a gate is closed by `tldrx approve` / `tldrx reject`, not by an answer. Approved → the loop carries on; rejected → it stops and prints the note, unless the rejection was `tldrx reject --and-continue`, which re-runs the stage with the note instead (#242); lapsed → exit 4 with the same lines it always had, after one `gate.timeout` notification. It WAITS FOR a signature and never produces one. A stage on `gates_policy: agent` has already had the engine's own gate signer run on it before this flag ever sees the gate (see the `gates_policy: agent` note below), so what is left to wait for here is a PERSON — the same wait a `human` gate gets. Nothing is spent while it waits. Both wait flags may be given together.",
15656
15657
  sub: "auto"
15657
15658
  },
15658
15659
  {
@@ -15988,6 +15989,11 @@ var ENTRIES = [
15988
15989
  args: [],
15989
15990
  flags: [
15990
15991
  { name: "note", arg: "<text>", meaning: "What has to change. Required — a rejection with no reason is not actionable." },
15992
+ {
15993
+ name: "and-continue",
15994
+ arg: null,
15995
+ meaning: "This rejection means “redo it this way and carry on”, not “stop, I will look”. The stage goes back to `ready` with the note exactly as a bare rejection leaves it — what changes is that an unattended `tldrx run auto --wait-gates` re-runs the stage instead of exiting 4, so a rejection sent from a phone does not need a walk to a terminal to take effect. Recorded on the gate, so the waiting loop reads it rather than guessing from the note’s words. Without it a rejection stops the loop, which is the default and always was. Refused with exit 1 beside `--stage`: a revoke leaves that gate pending for a decision nobody has made yet, so there is no rejection for it to describe."
15996
+ },
15991
15997
  {
15992
15998
  name: "stage",
15993
15999
  arg: "<phase>/<stage>",
@@ -15998,6 +16004,7 @@ var ENTRIES = [
15998
16004
  ],
15999
16005
  examples: [
16000
16006
  'tldrx reject --note "contracts.md does not name the events"',
16007
+ 'tldrx reject --and-continue --note "S2 fell over on a missing binary — redo S2 and waves 3 and 4"',
16001
16008
  'tldrx reject --stage 02-how/design --note "the auto gate signed over four open questions"'
16002
16009
  ],
16003
16010
  exits: [EXIT_OK, EXIT_USAGE, EXIT_GATE_REFUSED, EXIT_NOT_FOUND]
@@ -28963,6 +28970,9 @@ function blockedReasons(runDir) {
28963
28970
  }
28964
28971
  return out;
28965
28972
  }
28973
+ function gateStories(runDir, phaseId) {
28974
+ return phaseId === BUILD_PHASE2 ? storiesView(runDir) : null;
28975
+ }
28966
28976
  function gateStoriesPayload(view) {
28967
28977
  const blocked = view.firstBlocked;
28968
28978
  return {
@@ -30664,14 +30674,22 @@ function gatePhrase(policy) {
30664
30674
  return "gate";
30665
30675
  }
30666
30676
  }
30667
- function gateNotification(ctx, costUsd, policy = null, held2 = [], stories = null) {
30677
+ function clearingCommand(runId, openQuestions2, unfinishedStories) {
30678
+ const first2 = openQuestions2[0];
30679
+ if (first2 !== undefined)
30680
+ return answerCommand(first2, runId);
30681
+ if (unfinishedStories > 0)
30682
+ return `tldrx run status ${runId}`;
30683
+ return approveCommand(runId);
30684
+ }
30685
+ function gateNotification(ctx, costUsd, policy = null, held2 = [], stories = null, openQuestions2 = []) {
30668
30686
  const approve = approveCommand(ctx.runId);
30669
30687
  const why = held2.length === 0 ? "" : policy === "auto" ? ` It is held by: ${held2.join("; ")}.` : ` The engine's signer held it: ${held2.join("; ")}.`;
30670
- const delivered = stories === null ? "" : ` It ${deliveredPhrase(stories)}.`;
30688
+ const delivered = stories === null ? "" : ` It has ${deliveredPhrase(stories)}.`;
30671
30689
  return {
30672
30690
  ...base(ctx, "gate.requested"),
30673
30691
  summary: `${ctx.runId} finished ${ctx.stage ?? "a stage"} for $${costUsd.toFixed(2)} and is waiting ` + `at ${gateArticle(policy)} ${gatePhrase(policy)}.${delivered}${why} Nothing runs after it until the gate is ` + "approved or rejected.",
30674
- command: approve,
30692
+ command: clearingCommand(ctx.runId, openQuestions2, stories?.unfinished.length ?? 0),
30675
30693
  detail: {
30676
30694
  cost_usd: costUsd,
30677
30695
  approve_command: approve,
@@ -30737,7 +30755,7 @@ function runEndNotification(ctx, exitCode, spentUsd, lastLine2, tally = { usd: s
30737
30755
  }
30738
30756
  };
30739
30757
  }
30740
- function statusNotification(ctx, statusText, waitingOn = [], waitingOnGate = null, truncation = null) {
30758
+ function statusNotification(ctx, statusText, waitingOn = [], waitingOnGate = null, truncation = null, stories = null) {
30741
30759
  const ids = [...waitingOn];
30742
30760
  const parked = ids.length > 0;
30743
30761
  const gateSummary = waitingOnGate === null ? "" : `${ctx.runId} is parked at ${waitingOnGate.stage} waiting for a person to SIGN it: ` + `${gatePhrase(waitingOnGate.policy)}. Nothing runs after it and nothing is being spent ` + "while it waits." + (parked ? ` It also has ${String(ids.length)} open question(s): ${ids.join(", ")}.` : "");
@@ -30745,7 +30763,7 @@ function statusNotification(ctx, statusText, waitingOn = [], waitingOnGate = nul
30745
30763
  return {
30746
30764
  ...base(ctx, "status"),
30747
30765
  summary: (waitingOnGate !== null ? gateSummary : parked ? `${ctx.runId} is parked at ${ctx.stage ?? "an unnamed stage"} waiting on YOU: ` + `${String(ids.length)} open question(s), ${ids.join(", ")}. Nothing is being spent ` + "while it waits, and it resumes the moment one is answered." : `${ctx.runId} is still running at ${ctx.stage ?? "an unnamed stage"}. ` + "Nothing is waiting on you — this is the periodic heartbeat `--notify-every` asked for.") + tail2,
30748
- command: waitingOnGate !== null ? approveCommand(ctx.runId) : parked ? answerCommand(ids[0] ?? "Q1", ctx.runId) : `tldrx run status ${ctx.runId}`,
30766
+ command: waitingOnGate !== null || parked ? clearingCommand(ctx.runId, ids, stories?.unfinished.length ?? 0) : `tldrx run status ${ctx.runId}`,
30749
30767
  detail: {
30750
30768
  status_text: statusText,
30751
30769
  waiting_on: ids,
@@ -31055,15 +31073,23 @@ function reject(store, ctx) {
31055
31073
  ...stage2,
31056
31074
  status: "ready",
31057
31075
  ended_at: null,
31058
- gate: { ...stage2.gate, status: "rejected", by: ctx.actor, at: ctx.at, note: ctx.note }
31076
+ gate: {
31077
+ ...stage2.gate,
31078
+ status: "rejected",
31079
+ by: ctx.actor,
31080
+ at: ctx.at,
31081
+ note: ctx.note,
31082
+ and_continue: ctx.andContinue === true ? true : undefined
31083
+ }
31059
31084
  })));
31060
31085
  store.append(event2(ctx.at, store.runId, entry.stage.id, "gate.rejected", ctx.actor, {
31061
31086
  phase: entry.phase.id,
31062
31087
  note: ctx.note,
31063
- from
31088
+ from,
31089
+ ...ctx.andContinue === true ? { and_continue: true } : {}
31064
31090
  }));
31065
31091
  store.save();
31066
- return { stage: entry.stage.id, phase: entry.phase.id, note: ctx.note, from };
31092
+ return { stage: entry.stage.id, phase: entry.phase.id, note: ctx.note, from, andContinue: ctx.andContinue === true };
31067
31093
  }
31068
31094
  function revoke(store, ctx, target) {
31069
31095
  if (ctx.note.trim() === "") {
@@ -33636,6 +33662,14 @@ function emitPreflightYaml(preflight) {
33636
33662
  lines.push(` - repo: ${yamlScalar(row2.repo)}`, ` command: ${yamlScalar(row2.command)}`, ` base_ref: ${yamlScalar(row2.baseRef)}`, ` base_sha: ${yamlScalar(row2.baseSha)}`, ...typeof row2.exitCode === "number" ? [` exit_code: ${String(row2.exitCode)}`] : [], ` timed_out: ${row2.timedOut ? "true" : "false"}`, ` status: ${yamlScalar(row2.status)}`, ` tail: ${yamlScalar(row2.tail)}`);
33637
33663
  if (row2.refusedBecause !== undefined)
33638
33664
  lines.push(` refused_because: ${yamlScalar(row2.refusedBecause)}`);
33665
+ if (row2.excerpt !== undefined)
33666
+ lines.push(` excerpt: ${yamlScalar(row2.excerpt)}`);
33667
+ if (row2.outputPath !== undefined)
33668
+ lines.push(` output_path: ${yamlScalar(row2.outputPath)}`);
33669
+ if (row2.outputBytes !== undefined)
33670
+ lines.push(` output_bytes: ${String(row2.outputBytes)}`);
33671
+ if (row2.outputLine !== undefined)
33672
+ lines.push(` output_line: ${String(row2.outputLine)}`);
33639
33673
  if (row2.commandHash !== undefined)
33640
33674
  lines.push(` command_hash: ${yamlScalar(row2.commandHash)}`);
33641
33675
  if (row2.checkedAt !== undefined)
@@ -33678,6 +33712,8 @@ function parsePreflight(text3) {
33678
33712
  return null;
33679
33713
  }
33680
33714
  const hash = asText(row2.command_hash);
33715
+ const excerpt = asText(row2.excerpt);
33716
+ const outputPath = asText(row2.output_path);
33681
33717
  const rowCheckedAt = asText(row2.checked_at);
33682
33718
  results.push({
33683
33719
  repo,
@@ -33690,6 +33726,10 @@ function parsePreflight(text3) {
33690
33726
  ...refusedBecause === "" ? {} : { refusedBecause },
33691
33727
  status: row2.status === "ok" || row2.status === "failed" ? row2.status : "unmeasured",
33692
33728
  ...hash === "" ? {} : { commandHash: hash },
33729
+ ...excerpt === "" ? {} : { excerpt },
33730
+ ...outputPath === "" ? {} : { outputPath },
33731
+ ...Number.isInteger(row2.output_bytes) ? { outputBytes: row2.output_bytes } : {},
33732
+ ...Number.isInteger(row2.output_line) ? { outputLine: row2.output_line } : {},
33693
33733
  ...rowCheckedAt === "" ? {} : { checkedAt: rowCheckedAt }
33694
33734
  });
33695
33735
  }
@@ -33757,7 +33797,8 @@ function baseFailureLine(result2) {
33757
33797
  const at = result2.baseSha === "" ? "" : ` (${result2.baseSha})`;
33758
33798
  const why = result2.tail === "" ? "" : ` — ${result2.tail}`;
33759
33799
  const ran = result2.exitCode === undefined ? "was refused and never ran" : `exited ${String(result2.exitCode)}`;
33760
- return ` · \`${result2.command}\` ${ran}` + `${result2.timedOut ? " (timed out)" : ""} in repo ${result2.repo}` + ` on \`${result2.baseRef}\`${at}${why}`;
33800
+ const cite2 = result2.outputPath === undefined ? "" : ` [src: ${result2.outputPath}:${String(result2.outputLine ?? 1)}]`;
33801
+ return ` · \`${result2.command}\` ${ran}` + `${result2.timedOut ? " (timed out)" : ""} in repo ${result2.repo}` + ` on \`${result2.baseRef}\`${at}${why}${cite2}`;
33761
33802
  }
33762
33803
  function baseRefusalLines(failures, workspace) {
33763
33804
  const failed3 = [];
@@ -33888,6 +33929,9 @@ var FAILURE_RE = /FAIL|Failed|failed|\bfail\b|Error|error:|assert|✗|✖|not ok
33888
33929
  function dodOutputRel(storyId, index) {
33889
33930
  return `${BUILD_PHASE}/${LOG_DIR}/${DOD_OUTPUT_DIR}/${storyId}-${String(index + 1)}.txt`;
33890
33931
  }
33932
+ function baseOutputId(repo, command2) {
33933
+ return `base-${hashText(JSON.stringify([repo, command2]))}`;
33934
+ }
33891
33935
  function meaningfulLines(output) {
33892
33936
  return output.split(`
33893
33937
  `).map((line) => line.trimEnd()).filter((line) => line.trim() !== "");
@@ -33994,6 +34038,8 @@ async function baseResultOf(parts, repo, command2) {
33994
34038
  try {
33995
34039
  const outcome = await runDodCommand(command2, repoDir, timeoutMs, parts.workspace.commands);
33996
34040
  const exitCode = outcome.timedOut ? 124 : outcome.exitCode;
34041
+ const output = outcome.output ?? "";
34042
+ const kept = exitCode === 0 && !outcome.timedOut ? null : writeDodOutput(parts.runDir, baseOutputId(repo, command2), 0, output);
33997
34043
  measured = {
33998
34044
  repo,
33999
34045
  command: command2,
@@ -34001,7 +34047,13 @@ async function baseResultOf(parts, repo, command2) {
34001
34047
  baseSha,
34002
34048
  exitCode,
34003
34049
  timedOut: outcome.timedOut,
34004
- tail: outcome.tail,
34050
+ tail: kept === null ? outcome.tail : failureSummaryLine(output),
34051
+ ...kept === null ? {} : {
34052
+ excerpt: failureExcerpt(output),
34053
+ outputPath: kept.rel,
34054
+ outputBytes: kept.bytes,
34055
+ outputLine: kept.line
34056
+ },
34005
34057
  status: exitCode === 0 && !outcome.timedOut ? "ok" : "failed",
34006
34058
  commandHash: hash
34007
34059
  };
@@ -38193,6 +38245,7 @@ class BuildSession {
38193
38245
  at: this.ctx.at,
38194
38246
  preparing: this.ctx.mode === "prepare",
38195
38247
  timeoutMs: this.ctx.spec.planned.timeout_s * 1000,
38248
+ runDir: this.ctx.runDir,
38196
38249
  write: (work) => this.writes.run(work),
38197
38250
  advisories: this.advisories
38198
38251
  };
@@ -39615,7 +39668,7 @@ async function finishStage(store, options, phaseId, stageId, spec, notes, gateOv
39615
39668
  ...s,
39616
39669
  status: "awaiting_gate",
39617
39670
  ended_at: nowish(options),
39618
- gate: { ...s.gate, type: "approve", status: "pending" }
39671
+ gate: { ...s.gate, type: "approve", status: "pending", and_continue: undefined }
39619
39672
  }));
39620
39673
  const autoVerdict = policy === "auto" ? await evaluateAutoGate({
39621
39674
  root: options.root,
@@ -40326,7 +40379,8 @@ async function runAuto(options) {
40326
40379
  } catch {
40327
40380
  return;
40328
40381
  }
40329
- await notifier.send(statusNotification(notifyCtx(), text3, stillBlocking(runDir2), pendingGate(runDir2), cutInputs(stageIdOf())), stageIdOf());
40382
+ const gate2 = pendingGate(runDir2);
40383
+ await notifier.send(statusNotification(notifyCtx(), text3, stillBlocking(runDir2), gate2, cutInputs(stageIdOf()), gate2 === null ? null : gateStories(runDir2, gate2.phase)), stageIdOf());
40330
40384
  })();
40331
40385
  }, options.notifyEveryMs);
40332
40386
  const finish = async (code, spentUsd) => {
@@ -40384,11 +40438,11 @@ async function runAuto(options) {
40384
40438
  if (requested !== null && !autoApproved) {
40385
40439
  const cost = requested;
40386
40440
  const policy = gatePolicyNow(runDir2);
40387
- const stories = requestedPhase === BUILD_PHASE2 ? storiesView(runDir2) : null;
40441
+ const stories = gateStories(runDir2, requestedPhase);
40388
40442
  const send = async () => {
40389
40443
  if (notifier === null)
40390
40444
  return;
40391
- await notifier.send(gateNotification(notifyCtx(), cost, policy, gateHeld(fresh), stories), stageIdOf());
40445
+ await notifier.send(gateNotification(notifyCtx(), cost, policy, gateHeld(fresh), stories, stillBlocking(runDir2)), stageIdOf());
40392
40446
  };
40393
40447
  if (onlyHeldByQuestions(fresh))
40394
40448
  return { costUsd: cost, deferredGate: send };
@@ -40500,6 +40554,10 @@ async function runAuto(options) {
40500
40554
  continue;
40501
40555
  }
40502
40556
  if (waited.resolution === "rejected") {
40557
+ if (waited.andContinue) {
40558
+ say(`waited ${String(Math.round(waited.ms / 1000))}s at ${cursorBefore} — ` + `the gate on ${gate2.stage} was REJECTED with --and-continue` + (waited.note === null ? "" : `: ${waited.note}`) + " — re-running the stage with the note, resuming");
40559
+ continue;
40560
+ }
40503
40561
  rejection = `waited ${String(Math.round(waited.ms / 1000))}s at ${cursorBefore} — ` + `the gate on ${gate2.stage} was REJECTED` + (waited.note === null ? "" : `: ${waited.note}`);
40504
40562
  } else {
40505
40563
  await flushGate();
@@ -40561,6 +40619,7 @@ function pendingGate(runDir2) {
40561
40619
  return {
40562
40620
  stage: `${cursor.phase}/${cursor.stage}`,
40563
40621
  stageId: cursor.stage,
40622
+ phase: cursor.phase,
40564
40623
  policy: gatePolicyFor(store.run.gates_policy, cursor.stage)
40565
40624
  };
40566
40625
  } catch {
@@ -40580,16 +40639,22 @@ async function waitForGate(runDir2, stageId, limitMs, gate2) {
40580
40639
  for (;; ) {
40581
40640
  const elapsed = Date.now() - started;
40582
40641
  const found = gateOf(runDir2, stageId);
40583
- if (found !== null && found.status === "approved")
40584
- return { resolution: "approved", ms: elapsed, note: null };
40642
+ if (found !== null && found.status === "approved") {
40643
+ return { resolution: "approved", ms: elapsed, note: null, andContinue: false };
40644
+ }
40585
40645
  if (found !== null && found.status === "rejected") {
40586
- return { resolution: "rejected", ms: elapsed, note: found.note.trim() === "" ? null : found.note.trim() };
40646
+ return {
40647
+ resolution: "rejected",
40648
+ ms: elapsed,
40649
+ note: found.note.trim() === "" ? null : found.note.trim(),
40650
+ andContinue: found.andContinue
40651
+ };
40587
40652
  }
40588
40653
  if (found !== null && await selfCloseAutoGate(runDir2, stageId, gate2)) {
40589
- return { resolution: "approved", ms: Date.now() - started, note: null };
40654
+ return { resolution: "approved", ms: Date.now() - started, note: null, andContinue: false };
40590
40655
  }
40591
40656
  if (elapsed >= limitMs)
40592
- return { resolution: "lapsed", ms: elapsed, note: null };
40657
+ return { resolution: "lapsed", ms: elapsed, note: null, andContinue: false };
40593
40658
  const pollMs = pollInterval(limitMs);
40594
40659
  await new Promise((resolve11) => setTimeout(resolve11, Math.min(pollMs, limitMs - elapsed)));
40595
40660
  }
@@ -40622,7 +40687,11 @@ async function selfCloseAutoGate(runDir2, stageId, gate2) {
40622
40687
  function gateOf(runDir2, stageId) {
40623
40688
  try {
40624
40689
  const found = flatten(RunStore.open(runDir2).run).find((entry) => entry.stage.id === stageId);
40625
- return found === undefined ? null : { status: found.stage.gate.status, note: found.stage.gate.note };
40690
+ return found === undefined ? null : {
40691
+ status: found.stage.gate.status,
40692
+ note: found.stage.gate.note,
40693
+ andContinue: found.stage.gate.and_continue === true
40694
+ };
40626
40695
  } catch {
40627
40696
  return null;
40628
40697
  }
@@ -45134,11 +45203,12 @@ function costReport(argv) {
45134
45203
  var rejectCommand2 = {
45135
45204
  name: "reject",
45136
45205
  summary: "Request changes at the current gate, or revoke an approval already given",
45137
- usage: "tldrx reject --note <text> [--stage <phase>/<stage>] [--run <id>] [--root <path>]",
45206
+ usage: "tldrx reject --note <text> [--and-continue] [--stage <phase>/<stage>] [--run <id>] [--root <path>]",
45138
45207
  implemented: true,
45139
45208
  async run(argv) {
45140
45209
  try {
45141
45210
  const args = parseArgs(argv, ["run", "note", "root", "stage"]);
45211
+ const andContinue = boolFlag(args, "and-continue");
45142
45212
  const note = stringFlag(args, "note") ?? args.positionals.join(" ");
45143
45213
  if (note.trim() === "") {
45144
45214
  throw new UsageError('reject needs --note: `tldrx reject --note "what to change"`');
@@ -45146,11 +45216,14 @@ var rejectCommand2 = {
45146
45216
  const root2 = workspaceRootFrom(args);
45147
45217
  const wanted = stringFlag(args, "run");
45148
45218
  const target = stringFlag(args, "stage");
45219
+ if (andContinue && target !== undefined && target !== "") {
45220
+ throw new UsageError("--and-continue is about a rejection at the CURRENT gate; --stage revokes an approval " + "already given, which leaves that gate pending for a decision nobody has made yet");
45221
+ }
45149
45222
  const resolved = target === undefined || target === "" ? resolveRunOrExplain("tldrx reject", root2, wanted) : resolveIncludingFinished(root2, wanted);
45150
45223
  if (!isResolved(resolved))
45151
45224
  return resolved.exit;
45152
45225
  const store = resolved.store;
45153
- const ctx = { root: root2, actor: currentActor(), at: nowRfc3339(), note };
45226
+ const ctx = { root: root2, actor: currentActor(), at: nowRfc3339(), note, andContinue };
45154
45227
  if (target !== undefined && target !== "") {
45155
45228
  const outcome2 = revoke(store, ctx, target);
45156
45229
  const signed = outcome2.signedBy === "auto" ? "it had been auto-approved by the facilitator" : `it had been approved by ${outcome2.signedBy}`;
@@ -45171,9 +45244,11 @@ var rejectCommand2 = {
45171
45244
  }
45172
45245
  const outcome = reject(store, ctx);
45173
45246
  const came = outcome.from === "failed" ? " (it had failed)" : "";
45247
+ const loop = outcome.andContinue ? "an unattended `tldrx run auto --wait-gates` re-runs the stage instead of stopping (--and-continue)" : "an unattended `tldrx run auto --wait-gates` STOPS here — pass --and-continue to have it carry on instead";
45174
45248
  process.stdout.write(`rejected ${outcome.phase}/${outcome.stage}${came} — back to \`ready\`
45175
45249
  ` + `note: ${outcome.note}
45176
45250
  the note goes into the next prompt — \`tldrx next\` to re-run the stage
45251
+ ` + `${loop}
45177
45252
  `);
45178
45253
  return EXIT_OK;
45179
45254
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tldr-experts",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
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.16.0",
5
+ "version": "0.16.1",
6
6
  "author": {
7
7
  "name": "Alan Martinez"
8
8
  }