tldr-experts 0.21.0 → 0.23.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,96 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.23.0 — 2026-09-14
4
+
5
+ ### Fixed
6
+
7
+ - **A fix list with nothing to fix now settles the story `done` instead of buying a developer round
8
+ (#295, first half).** Measured on a live unattended run (0.20.0): the reviewer signed a two-finding
9
+ fix list, both `docs` and both `defer-with-log`, and the story still parked at `review` — the Build
10
+ then spawned a fix round for it, which died four times on command-form refusals (#294) with nothing
11
+ to fix. The rule that a `fixlist` verdict parks the story was written when a fix list meant "here is
12
+ what the author must do next"; #255 then made all-deferred lists the normal outcome of a docs-only
13
+ review and never adjusted who settles the story. Now the count the operator line already prints —
14
+ findings still `fix-now` in the PARSED list, #255's routing included — decides: zero means the
15
+ reviewer signed and nothing is owed a developer, so the story is `done` with `verdict: fixlist` on
16
+ its record. The artifact is still written and its deferred findings still reach `retro.md`: settling
17
+ the story does not settle what it deferred. One `fix-now` finding parks it exactly as before.
18
+ - **`story reopen --as-is` can settle a story whose work is already on the epic and whose review
19
+ never completed — review-only, merging nothing (#295, second half).** Under merge-before-review
20
+ (#282) a story's diff can sit entirely on the epic while nothing has judged it: measured twice on
21
+ 2026-09-13, a reviewer that died and a later attempt that died on a bare `echo $?` with no work.
22
+ #279's guard — the branch must carry a commit the epic has not got — is right for a branch with no
23
+ work and blind to this mirror image, where there is nothing to MERGE and something to SETTLE; the
24
+ operator's only moves were inventing a commit for `--as-is` to take, which corrupts the
25
+ measurement, or leaving the story blocked with its dependents (#280). The guard is not relaxed. A
26
+ new, named case sits beside it: the review ledger now keeps the story's last recorded merge
27
+ (`commit` + `epic_base`) across reopen boundaries — a reopen resets what counts against a story,
28
+ not what the epic holds — and `--as-is` over a branch that carries nothing beyond its epic asks
29
+ whether anything judged that merge. `n-a` and `error` mean nothing did, and the turn runs
30
+ review-only: the branch is brought up to the epic `--ff-only` (nothing of its own to lose), the
31
+ DoD runs on the epic head, the reviewer is handed the recorded range, and nothing is merged —
32
+ `task.done` carries `as_is_reason: review-only`, the review log's developer line says so and names
33
+ the commit it reviewed. A review that STANDS (`changes`, `approve`, `fixlist`) still refuses, and
34
+ the refusal now names that verdict beside the #279 sentence: what is owed is a fix, not a third
35
+ opinion on the same bytes — which is also why the #279 pin test (two `changes` over a reset
36
+ branch) is untouched. The issue proposed "any verdict but `approve`"; that would have flipped that
37
+ pin, so the boundary shipped is the one in the issue's title: the review never completed.
38
+ - **A turn's failure record can no longer borrow the word `success` from the provider (#296).**
39
+ MEASURED on two live unattended runs while the account sat over its usage limit: both a
40
+ developer and a reviewer died with `claude exited 1 with is_error=true: success`. That line is
41
+ two layers' verdicts concatenated and nothing reconciling them — our `is_error` beside the
42
+ provider's own `subtype`, which was the literal `"success"` — and the ONLY human-readable word
43
+ on it was the one that was wrong. Tomorrow's reader learns the story failed; the truth is it
44
+ never ran, which is exactly the direction §7 forbids an audit record to lie in. `describe()`
45
+ is reached only for a FAILURE, so a subtype asserting the turn succeeded cannot be that
46
+ failure's reason: it is now refused as a reason and reported as the contradiction it is —
47
+ `claude exited 1 with is_error=true: no reason named (the provider's own subtype said
48
+ "success")` — keeping BOTH facts rather than dropping either, because a record that hides the
49
+ disagreement is only marginally better than one that resolves it wrongly. A failure with no
50
+ reason anywhere now says `no reason named` instead of trailing off after the exit code. A
51
+ reason the provider DID name (`Reached maximum budget ($0.26)`) and a genuine error subtype
52
+ (`error_during_execution`) are printed exactly as before. **This does not classify the limit
53
+ and does not change any retry**: what `subtype`, `errors[]` or HTTP status either provider
54
+ emits on a quota limit is still unmeasured, and a detector built on a guessed signal is a
55
+ detector that never fires — so the record is fixed today and the cause stays honestly unnamed
56
+ until the raw document of a rate-limited turn is captured.
57
+
58
+ ## 0.22.0 — 2026-09-14
59
+
60
+ ### Fixed
61
+
62
+ - **A developer may now READ its own tree, `git -C` is refused on purpose, and every cure says
63
+ WHY it is a cure (#287, #294).** Two issues, one edit, because they are the same defect seen
64
+ twice. The developer's git allowance held `add`, `commit`, `rm`, `mv`, `restore` and **no read
65
+ verb at all** — it could write its worktree and never look at it. Measured on a live
66
+ unattended run: a developer ran `git -C <worktree> log --oneline -5`, was refused, was
67
+ re-spawned with the cure, was refused again, and the story died — ~$5 for a command that
68
+ changes nothing. `status`, `log`, `diff` and `show` join the ONE constant the grant, the
69
+ prompt's list and the refusal classifier all read (`build/developerGrants.ts`), so all three
70
+ gained them in the same edit. **`-C <path>` (and `--git-dir`, `--work-tree`) stays ungranted
71
+ as a decision, and the code now says so**: `-C` points git at ANY directory — the epic
72
+ worktree, another story's worktree, the shared checkout — so granting it would turn a read
73
+ verb into a read of trees the story does not own, and a mutating verb into a write there. It
74
+ is its own refusal kind (`elsewhere`) whose cure is "drop `-C <path>` and run git from your
75
+ own worktree", never "ask for `-C`". The second half is the reason these shipped together:
76
+ the three cures we had — "run each command alone" (#278), "run `git log` from your own
77
+ worktree" (#287), "don't append `echo`" (#294) — all said WHAT and none said WHY, and an agent
78
+ that does not know why finds another way to do the same thing. #294 is the proof: three
79
+ consecutive developers on ONE story appended `; echo "EXIT:$?"` to a DoD command, each was
80
+ refused, and the cure kept telling them to separate the commands — but from their side the
81
+ `echo` was not an extra command, it was HOW you read an exit code, so they re-added it. A
82
+ `separator` classification now carries `capturing` (the chain exists only to capture the
83
+ outcome: an `echo` naming `$?`, or a redirect of the command's own output), and that cure, the
84
+ retry prefix and the developer prompt all append the same one clause, from one constant: the
85
+ facilitator re-runs the Definition of Done after you and records each command's exit code, so
86
+ the number the `echo` would print is written down whether you capture it or not. That
87
+ clause says only what this repo measures (`build/dodRunner.ts`) and only what holds for every
88
+ provider: whether an agent's own execution tool hands the exit code back to the model is a
89
+ property of the provider's CLI — measured for Claude Code's `Bash` tool, established nowhere
90
+ for `codex exec` — and the sentence goes into the prompt BOTH providers read, so asserting it
91
+ would have been this very bug one level up, a plausible explanation instead of a true one. The
92
+ developer prompt goldens moved for exactly those two paragraphs.
93
+
3
94
  ## 0.21.0 — 2026-09-13
4
95
 
5
96
  ### Fixed
package/README.md CHANGED
@@ -335,6 +335,8 @@ back on the registry is 0.3.0.
335
335
 
336
336
  | Version | Date | Status | Contains |
337
337
  |---|---|---|---|
338
+ | 0.23.0 | 2026-09-14 | `beta` | `tldrx story reopen --as-is` now settles a story whose work is already merged into the epic but whose review never completed (`n-a` or `error` on its last recorded merge): a new, named review-only case that merges nothing and only routes the story to its reviewer, read from an additive ledger field; the existing refusal ("no commit the epic has not got") is untouched and now names the standing verdict when one exists (`changes`, `approve`, `fixlist` still refuse). A fix list whose findings are all deferred settles the story `done` instead of spawning a developer with nothing to fix, measured on a live run that paid for four such developers (#295). And a turn that died on a provider limit no longer records `success` borrowed from the provider's own subtype: the failure record says what the host saw (#296, first half — DETECTING the provider limit as its own non-execution kind is #298 and is NOT in this release). Minor release: `story reopen --as-is` behaves differently in a situation that exists today. |
339
+ | 0.22.0 | 2026-09-14 | `beta` | A developer may now READ its own tree — `git status`, `log`, `diff` and `show` join the one constant the grant, the developer prompt and the refusal classifier all read; measured on a live unattended run where a developer was refused `git -C <worktree> log` twice and the story died, ~$5 for a command that changes nothing. `-C <path>` (and `--git-dir`, `--work-tree`) stays ungranted as a decision with its own refusal kind, `elsewhere`, because it points git at trees the story does not own. And every refusal cure now says WHY it is a cure: three consecutive developers on one story re-appended `; echo "EXIT:$?"` to a DoD command because the cure said what to drop and never that the facilitator re-runs the Definition of Done itself and records each exit code (#287, #294). Minor release: the developer's git allowance grew and a new refusal kind was added. |
338
340
  | 0.21.0 | 2026-09-13 | `beta` | `tldrx budget raise <phase> <usd> --stage <id>` now moves the stage's own `budget_usd` — the figure that actually sets a developer's and a reviewer's spawn ceiling — instead of the phase figure, which caps no spawn at all: measured on a live unattended run where raising the plan's per-story price and the phase ceiling moved a developer's cap by nothing, and only raising the stage figure moved it; a raise naming no `--stage` now says outright that it moved no spawn ceiling. And a reviewer a nearly-exhausted stage cannot fund is refused before it is spawned — the run that surfaced this handed a reviewer **$0.43**, which died before reading a line of diff and recorded `verdict: error`, parking the story with its dependents blocked; the refusal now costs $0 and records `verdict: n-a`, not an error the reviewer never formed (#244, #289). Minor release: a new flag, `--stage`, on `budget raise`. |
339
341
  | 0.20.0 | 2026-09-13 | `beta` | `tldrx seed check <file\|dir>` runs a hand-written seed through the same importer chain `run new --seed` runs, plus the authoring rules four unattended runs paid to learn — bullets under 200 characters, `[src:]` citations that resolve, `dod` lines byte-equal to a declared command with no shell separator, `depends_on` wherever two stories touch one file, a `Recommended:` on every open question — and prints the budget split and per-story caps a run would set, creating no run and spending nothing; `tldrx install --claude` now writes a second managed skill, `tldrx-plan`, that reads `workspace.yml` and the repo tree and turns "I want X" into seeds that pass the same check, naming only declared tools (#291). And a permission refusal over a command the workspace's `commands:` could have granted now names the operator's cure on the first dead developer instead of the fourth, measured on a live unattended run where four developers died and two review rounds ran before the missing command turned out to be one `workspace.yml` line away (#285). Minor release: a new CLI verb, `tldrx seed check`. |
340
342
  | 0.19.0 | 2026-09-13 | `beta` | `tldrx story reopen <id> --as-is --note "…"` settles a story a person finished by hand from its branch as it stands, spawning no developer — measured on a live unattended run where a hand-rebased branch had a green DoD and a clean merge-tree but no way to land, blocked by #271's since-the-spawn rule from a developer with nothing to do (#279). Everything after the missing developer is the ordinary path: the epic merges in first if it moved, the same DoD and reviewer judge the diff, the same merge lands it. The record never lies about who did the work — `story.reopened` carries `reason: as_is` with the actor and note, `task.done` carries `as_is`/`as_is_by`/`as_is_note`, and the review log names the branch and the signer. Two refusals are structural with no flag to skip either: a branch with no commit ahead of its epic, and a red DoD. The signature clears the moment a real developer attempt starts, so a requeued story after a `changes` verdict records a developer, not an as-is. Minor release: a new flag, `--as-is`, on `story reopen`. |
@@ -22,7 +22,7 @@ import {
22
22
  validateRunBudget,
23
23
  wouldExceed,
24
24
  wouldExceedHostTokens
25
- } from "./chunk-f5wkn522.js";
25
+ } from "./chunk-1p5027fn.js";
26
26
  import {
27
27
  EventLog
28
28
  } from "./chunk-6z5rmj0b.js";
@@ -728,6 +728,9 @@ var KINDS = {
728
728
  var MAX_FIXLIST_ROUNDS = STAGE_TUNING_DEFAULTS.fixlistRounds;
729
729
  var FINDING_KINDS = ["correctness", "security", "docs", "style"];
730
730
 
731
+ // src/core/build/refusalKind.ts
732
+ var OUTCOME_ALREADY_REPORTED = "the facilitator re-runs the Definition of Done after you and records each command's exit " + "code, so the number the `echo` would print is measured and written down whether you " + "capture it or not";
733
+
731
734
  // src/core/build/prompts.ts
732
735
  var MAX_TOUCHED_BYTES = 64 * 1024;
733
736
  var REVIEW_SCHEMA = {
@@ -8,7 +8,7 @@ import {
8
8
  spentBasis,
9
9
  tallyOf,
10
10
  validateRunBudget
11
- } from "./chunk-f5wkn522.js";
11
+ } from "./chunk-1p5027fn.js";
12
12
  import {
13
13
  EventLog,
14
14
  OUTCOME_NOT_RECORDED,
@@ -20,14 +20,14 @@ import {
20
20
  runSnapshot,
21
21
  statusWithOutcome,
22
22
  whatIsWaiting
23
- } from "./chunk-6qnsx1tz.js";
23
+ } from "./chunk-ah1pdax3.js";
24
24
  import {
25
25
  expertsDir,
26
26
  loadExperts,
27
27
  pathsIntersect,
28
28
  readExpertDomain,
29
29
  stackExpertNames
30
- } from "./chunk-f5wkn522.js";
30
+ } from "./chunk-1p5027fn.js";
31
31
  import {
32
32
  isFinished
33
33
  } from "./chunk-6z5rmj0b.js";
@@ -2,8 +2,8 @@
2
2
  import {
3
3
  bar,
4
4
  runSnapshot
5
- } from "./chunk-6qnsx1tz.js";
6
- import"./chunk-f5wkn522.js";
5
+ } from "./chunk-ah1pdax3.js";
6
+ import"./chunk-1p5027fn.js";
7
7
  import"./chunk-6z5rmj0b.js";
8
8
  import"./chunk-m1s8a6s0.js";
9
9
  import"./chunk-yre2scxn.js";
package/dist/tldrx.js CHANGED
@@ -16449,7 +16449,7 @@ var ENTRIES = [
16449
16449
  {
16450
16450
  name: "as-is",
16451
16451
  arg: null,
16452
- meaning: "Settle the story from its BRANCH AS IT STANDS: the next Build turn runs the dod, the review and the merge over the commits already on `story/<run>/<id>`, and spawns NO developer. For a story a person finished by hand — a rebase after a conflict, a fix typed directly on the branch — which the plain verb cannot land, because a developer handed finished work has nothing to do and the story blocks on the no-work rule. It is not a shortcut past a gate: a red dod blocks the story, and so does a branch carrying no commit its epic has not already got. Requires --note; refused together with --for-fix.",
16452
+ meaning: "Settle the story from its BRANCH AS IT STANDS: the next Build turn runs the dod, the review and the merge over the commits already on `story/<run>/<id>`, and spawns NO developer. For a story a person finished by hand — a rebase after a conflict, a fix typed directly on the branch — which the plain verb cannot land, because a developer handed finished work has nothing to do and the story blocks on the no-work rule. It is not a shortcut past a gate: a red dod blocks the story, and so does a branch carrying no commit its epic has not already got — with one named exception: a story whose work is ALREADY on the epic from an earlier turn and whose review never completed (the reviewer died, or the attempt after the merge died with no work) is settled review-only: nothing is merged, the dod runs on the epic head and the reviewer judges the range the story was merged as; a review that stands (`approve`, `changes`, `fixlist`) still refuses. Requires --note; refused together with --for-fix.",
16453
16453
  sub: "reopen"
16454
16454
  },
16455
16455
  runFlag(),
@@ -20652,10 +20652,17 @@ function describe4(exitCode, doc, stderr, timedOut, stdout, provider) {
20652
20652
  return `${name} exited ${exitCode} without a parseable result event: ${tail}`;
20653
20653
  }
20654
20654
  const errors2 = Array.isArray(doc.errors) ? doc.errors.filter((e) => typeof e === "string") : [];
20655
- const reason = errors2[0] ?? (typeof doc.subtype === "string" ? doc.subtype : "") ?? "";
20656
- const suffix = reason === "" ? "" : `: ${String(reason)}`;
20657
- return `${name} exited ${exitCode} with is_error=${String(doc.is_error === true)}${suffix}`;
20658
- }
20655
+ const verdict = `${name} exited ${exitCode} with is_error=${String(doc.is_error === true)}`;
20656
+ const named = typeof errors2[0] === "string" && errors2[0] !== "" ? errors2[0] : "";
20657
+ if (named !== "")
20658
+ return `${verdict}: ${named}`;
20659
+ const subtype = typeof doc.subtype === "string" ? doc.subtype : "";
20660
+ if (subtype !== "" && !SUCCESS_SUBTYPES.has(subtype))
20661
+ return `${verdict}: ${subtype}`;
20662
+ const contradiction = subtype === "" ? "" : ` (the provider's own subtype said "${subtype}")`;
20663
+ return `${verdict}: no reason named${contradiction}`;
20664
+ }
20665
+ var SUCCESS_SUBTYPES = new Set(["success"]);
20659
20666
  function firstLine3(text2) {
20660
20667
  const line = text2.split(`
20661
20668
  `).map((l) => l.trim()).find((l) => l !== "");
@@ -25937,6 +25944,7 @@ function carriedFindings(findings2) {
25937
25944
  return findings2.filter((f) => f.disposition === "defer-with-log" && !(f.resolved && f.resolvedSha !== null));
25938
25945
  }
25939
25946
  var CLAIMED_UNVERIFIED = "claimed-unverified";
25947
+ var FIXLIST_SETTLED_MARK = "every finding is routed away from `fix-now`, so the story is done without a fix round";
25940
25948
  function parseFixFindings(value) {
25941
25949
  const problems = [];
25942
25950
  const format = [];
@@ -26452,8 +26460,16 @@ function reviewStillOwed(outcome) {
26452
26460
  }
26453
26461
  var AS_IS_MARK = "the branch was taken AS IT STANDS — no developer was spawned for it";
26454
26462
  var AS_IS_NOT_AHEAD_MARK = "nothing to take as it stands";
26455
- function asIsNotAheadReason(branch, epicBranch, ahead) {
26456
- return ahead === null ? `${AS_IS_NOT_AHEAD_MARK}: git could not count what \`${branch}\` carries beyond ` + `\`${epicBranch}\`, and a settlement that spawns no developer will not merge on a ` + "measurement it does not have. Nothing was merged" : `${AS_IS_NOT_AHEAD_MARK}: \`${branch}\` carries no commit \`${epicBranch}\` has not already ` + "got, so there is no work to settle. Commit the fix to the story branch first, then reopen it " + "--as-is. Nothing was merged";
26463
+ var AS_IS_JUDGED_MARK = "the last review of that work stands";
26464
+ var AS_IS_REVIEW_ONLY_MARK = "the review was run AS IT STANDS — nothing was merged and no developer was spawned";
26465
+ function asIsNotAheadReason(branch, epicBranch, ahead, judged) {
26466
+ if (ahead === null) {
26467
+ return `${AS_IS_NOT_AHEAD_MARK}: git could not count what \`${branch}\` carries beyond ` + `\`${epicBranch}\`, and a settlement that spawns no developer will not merge on a ` + "measurement it does not have. Nothing was merged";
26468
+ }
26469
+ return `${AS_IS_NOT_AHEAD_MARK}: \`${branch}\` carries no commit \`${epicBranch}\` has not already ` + "got, so there is no work to settle" + (judged === undefined ? "" : `, and ${AS_IS_JUDGED_MARK} — \`${judged.commit.slice(0, 7)}\` is on \`${epicBranch}\` and its ` + `review said \`${judged.verdict}\`, so the review is not what is owed`) + ". Commit the fix to the story branch first, then reopen it --as-is. Nothing was merged";
26470
+ }
26471
+ function reviewNeverCompleted(verdict) {
26472
+ return verdict === "n-a" || verdict === "error";
26457
26473
  }
26458
26474
  function scopedNote(result2) {
26459
26475
  if (result2.scope !== "paths" || result2.rendered === undefined)
@@ -26477,7 +26493,23 @@ function dodFailureReason(result2, repo) {
26477
26493
  }
26478
26494
 
26479
26495
  // src/core/build/developerGrants.ts
26480
- var DEVELOPER_GIT_VERBS = ["add", "commit", "rm", "mv", "restore"];
26496
+ var DEVELOPER_GIT_VERBS = [
26497
+ "add",
26498
+ "commit",
26499
+ "rm",
26500
+ "mv",
26501
+ "restore",
26502
+ "status",
26503
+ "log",
26504
+ "diff",
26505
+ "show"
26506
+ ];
26507
+ var GIT_ELSEWHERE_OPTIONS = ["-C", "--git-dir", "--work-tree"];
26508
+ function gitElsewhereOption(argv) {
26509
+ const word = argv[1] ?? "";
26510
+ const flag = word.includes("=") ? word.slice(0, word.indexOf("=")) : word;
26511
+ return GIT_ELSEWHERE_OPTIONS.includes(flag) ? flag : null;
26512
+ }
26481
26513
  function developerGitGrants() {
26482
26514
  return DEVELOPER_GIT_VERBS.map((verb) => `Bash(git ${verb} *)`);
26483
26515
  }
@@ -26502,6 +26534,94 @@ function slotForCommand(line) {
26502
26534
  return SUBCOMMAND_RE.test(second) ? `${head} ${second}` : head;
26503
26535
  }
26504
26536
 
26537
+ // src/core/build/refusalKind.ts
26538
+ var MAX_SEPARATOR_RETRIES = 1;
26539
+ function classifyRefusal(command2, declared) {
26540
+ const separator = unquotedShellSeparator(command2);
26541
+ if (separator !== null) {
26542
+ return { kind: "separator", separator, capturing: capturesOutcome(command2, separator) };
26543
+ }
26544
+ const argv = command2.trim().split(/\s+/);
26545
+ if (argv[0] !== "git")
26546
+ return undeclaredKind(command2, declared);
26547
+ const verb = argv[1] ?? "";
26548
+ const elsewhere = gitElsewhereOption(argv);
26549
+ if (elsewhere !== null)
26550
+ return { kind: "elsewhere", option: elsewhere };
26551
+ if (verb === "" || verb.startsWith("-"))
26552
+ return { kind: "unknown" };
26553
+ if (isDeveloperGitVerb(verb))
26554
+ return { kind: "unknown" };
26555
+ return { kind: "verb", verb, equivalent: grantedEquivalent(verb, argv.slice(2)) };
26556
+ }
26557
+ function undeclaredKind(command2, declared) {
26558
+ if (declared === undefined)
26559
+ return { kind: "unknown" };
26560
+ if (grantsCommand(declared, command2))
26561
+ return { kind: "unknown" };
26562
+ const slot = slotForCommand(command2);
26563
+ return slot === "" ? { kind: "unknown" } : { kind: "undeclared", slot };
26564
+ }
26565
+ function grantedEquivalent(verb, args) {
26566
+ if (verb === "checkout") {
26567
+ const switching = args.some((a) => a === "-b" || a === "-B" || a === "--orphan" || a === "-t" || a === "--track");
26568
+ if (switching)
26569
+ return null;
26570
+ const hasTarget = args.some((a) => !a.startsWith("-")) || args.includes("--");
26571
+ return hasTarget ? "git restore <path>" : null;
26572
+ }
26573
+ if (verb === "reset") {
26574
+ const mode = args.some((a) => /^--(hard|soft|mixed|merge|keep)$/.test(a));
26575
+ if (mode)
26576
+ return null;
26577
+ if (args.includes("--"))
26578
+ return "git restore --staged <path>";
26579
+ const [first, second] = args.filter((a) => !a.startsWith("-"));
26580
+ if (first === "HEAD" && second !== undefined)
26581
+ return "git restore --staged <path>";
26582
+ return null;
26583
+ }
26584
+ return null;
26585
+ }
26586
+ var EXIT_CODE_ECHO_RE = /(^|[;&|])\s*echo\b[^;&|]*\$\?/;
26587
+ function capturesOutcome(command2, separator) {
26588
+ if (EXIT_CODE_ECHO_RE.test(command2))
26589
+ return true;
26590
+ return separator === ">" || separator === ">>" || separator === "2>&1";
26591
+ }
26592
+ var OUTCOME_ALREADY_REPORTED = "the facilitator re-runs the Definition of Done after you and records each command's exit " + "code, so the number the `echo` would print is measured and written down whether you " + "capture it or not";
26593
+ function refusalCure(kind) {
26594
+ switch (kind.kind) {
26595
+ case "separator":
26596
+ return "run each command alone — shell separators split a line into subcommands that each need their own grant" + (kind.capturing ? `. The exit code is not lost by dropping it: ${OUTCOME_ALREADY_REPORTED}` : "");
26597
+ case "elsewhere":
26598
+ return `drop \`${kind.option} <path>\` and run git from your own worktree — \`${kind.option}\` ` + "points git at another directory, so it is refused on purpose rather than missing from the " + "allowance: it would reach trees this story does not own. Your working directory already is " + "the worktree, and the read verbs run there";
26599
+ case "verb":
26600
+ return kind.equivalent === null ? `\`git ${kind.verb}\` is not granted` : `\`git ${kind.verb}\` is not granted; use \`${kind.equivalent}\``;
26601
+ case "undeclared":
26602
+ return `nothing in .tldrx/workspace.yml's \`commands:\` grants \`${kind.slot}\` — a developer's ` + "grant is built from the declared commands, so no re-run and no reopen note can make this " + `line runnable. The operator's cure: add a \`commands:\` slot whose value is exactly ` + `\`${kind.slot}\` (a slot grants that string plus any arguments), or a longer prefix of the ` + "refused line if less should be granted";
26603
+ case "unknown":
26604
+ return "";
26605
+ }
26606
+ }
26607
+ function withCure(sentence, command2, declared) {
26608
+ const cure = refusalCure(classifyRefusal(command2, declared));
26609
+ return cure === "" ? sentence : `${sentence}. The cure: ${cure}`;
26610
+ }
26611
+ function separatorCurePrefix(command2) {
26612
+ const separator = unquotedShellSeparator(command2);
26613
+ const capturing = separator !== null && capturesOutcome(command2, separator);
26614
+ return [
26615
+ `Your previous command \`${command2}\` was refused because it chains commands. Run each command alone.`,
26616
+ "The permission layer splits a line at every shell separator (`&&`, `;`, `|`, `>`, `2>&1`, `$()`)",
26617
+ "and each fragment must match its own grant, so a compound line is refused even when every",
26618
+ "command in it is allowed on its own. This is the one retry: a second refusal blocks the story.",
26619
+ ...capturing ? [`The exit code is not lost by dropping it: ${OUTCOME_ALREADY_REPORTED}.`] : [],
26620
+ ""
26621
+ ].join(`
26622
+ `);
26623
+ }
26624
+
26505
26625
  // src/core/build/prompts.ts
26506
26626
  var MAX_TOUCHED_BYTES = 64 * 1024;
26507
26627
  var MAX_TOUCHED_FILES = 24;
@@ -26605,8 +26725,11 @@ function buildDeveloperPrompt(parts) {
26605
26725
  "",
26606
26726
  ...parts.testFast === undefined ? [] : [...testFastRule(parts.testFast.fast, parts.testFast.full), ""],
26607
26727
  `Commit with \`git add\` and \`git commit\`. The git verbs you hold are exactly ${gitVerbList()}.`,
26608
- "`git restore <path>` is how to put a file back (there is no checkout in that list). Nothing",
26609
- "else about git is yours to do.",
26728
+ "`git restore <path>` is how to put a file back (there is no checkout in that list). The read",
26729
+ "verbs are yours so you can look at your own tree before you commit — run them from your working",
26730
+ "directory, which already IS this worktree. Never `-C <path>`: it points git at another directory,",
26731
+ "which is not yours to read or write, so it is refused on purpose. Nothing else about git is",
26732
+ "yours to do.",
26610
26733
  "",
26611
26734
  "## Rules",
26612
26735
  "",
@@ -26619,8 +26742,9 @@ function buildDeveloperPrompt(parts) {
26619
26742
  "- Run each Definition of Done command verbatim and alone: no redirection, pipes or chaining.",
26620
26743
  " Shell separators (`>`, `>>`, `2>&1`, `<`, `|`, `;`, `&&`, `||`, `&`, `$()`) split a line into",
26621
26744
  " subcommands, and each subcommand must match its own grant, so a compound line is refused",
26622
- " even when the script itself is allowed. The facilitator re-runs the Definition of Done",
26623
- " after you anyway.",
26745
+ " even when the script itself is allowed.",
26746
+ `- Do not append \`; echo $?\` or redirect a command's output to a file to capture its outcome.`,
26747
+ ` The exit code is not lost by dropping it: ${OUTCOME_ALREADY_REPORTED}.`,
26624
26748
  "",
26625
26749
  "### Conventions",
26626
26750
  "",
@@ -28715,78 +28839,6 @@ function verdictReviewer(checkPayload, lastSpawn) {
28715
28839
  return lastSpawn;
28716
28840
  }
28717
28841
 
28718
- // src/core/build/refusalKind.ts
28719
- var MAX_SEPARATOR_RETRIES = 1;
28720
- function classifyRefusal(command2, declared) {
28721
- const separator = unquotedShellSeparator(command2);
28722
- if (separator !== null)
28723
- return { kind: "separator", separator };
28724
- const argv = command2.trim().split(/\s+/);
28725
- if (argv[0] !== "git")
28726
- return undeclaredKind(command2, declared);
28727
- const verb = argv[1] ?? "";
28728
- if (verb === "" || verb.startsWith("-"))
28729
- return { kind: "unknown" };
28730
- if (isDeveloperGitVerb(verb))
28731
- return { kind: "unknown" };
28732
- return { kind: "verb", verb, equivalent: grantedEquivalent(verb, argv.slice(2)) };
28733
- }
28734
- function undeclaredKind(command2, declared) {
28735
- if (declared === undefined)
28736
- return { kind: "unknown" };
28737
- if (grantsCommand(declared, command2))
28738
- return { kind: "unknown" };
28739
- const slot = slotForCommand(command2);
28740
- return slot === "" ? { kind: "unknown" } : { kind: "undeclared", slot };
28741
- }
28742
- function grantedEquivalent(verb, args) {
28743
- if (verb === "checkout") {
28744
- const switching = args.some((a) => a === "-b" || a === "-B" || a === "--orphan" || a === "-t" || a === "--track");
28745
- if (switching)
28746
- return null;
28747
- const hasTarget = args.some((a) => !a.startsWith("-")) || args.includes("--");
28748
- return hasTarget ? "git restore <path>" : null;
28749
- }
28750
- if (verb === "reset") {
28751
- const mode = args.some((a) => /^--(hard|soft|mixed|merge|keep)$/.test(a));
28752
- if (mode)
28753
- return null;
28754
- if (args.includes("--"))
28755
- return "git restore --staged <path>";
28756
- const [first2, second] = args.filter((a) => !a.startsWith("-"));
28757
- if (first2 === "HEAD" && second !== undefined)
28758
- return "git restore --staged <path>";
28759
- return null;
28760
- }
28761
- return null;
28762
- }
28763
- function refusalCure(kind) {
28764
- switch (kind.kind) {
28765
- case "separator":
28766
- return "run each command alone — shell separators split a line into subcommands that each need their own grant";
28767
- case "verb":
28768
- return kind.equivalent === null ? `\`git ${kind.verb}\` is not granted` : `\`git ${kind.verb}\` is not granted; use \`${kind.equivalent}\``;
28769
- case "undeclared":
28770
- return `nothing in .tldrx/workspace.yml's \`commands:\` grants \`${kind.slot}\` — a developer's ` + "grant is built from the declared commands, so no re-run and no reopen note can make this " + `line runnable. The operator's cure: add a \`commands:\` slot whose value is exactly ` + `\`${kind.slot}\` (a slot grants that string plus any arguments), or a longer prefix of the ` + "refused line if less should be granted";
28771
- case "unknown":
28772
- return "";
28773
- }
28774
- }
28775
- function withCure(sentence, command2, declared) {
28776
- const cure = refusalCure(classifyRefusal(command2, declared));
28777
- return cure === "" ? sentence : `${sentence}. The cure: ${cure}`;
28778
- }
28779
- function separatorCurePrefix(command2) {
28780
- return [
28781
- `Your previous command \`${command2}\` was refused because it chains commands. Run each command alone.`,
28782
- "The permission layer splits a line at every shell separator (`&&`, `;`, `|`, `>`, `2>&1`, `$()`)",
28783
- "and each fragment must match its own grant, so a compound line is refused even when every",
28784
- "command in it is allowed on its own. This is the one retry: a second refusal blocks the story.",
28785
- ""
28786
- ].join(`
28787
- `);
28788
- }
28789
-
28790
28842
  // src/core/build/review.ts
28791
28843
  var VERDICT_WORDS = ["approve", "fixlist", "changes"];
28792
28844
  var VERDICT_ENUM = VERDICT_WORDS.join("|");
@@ -28972,7 +29024,7 @@ function renderReviewLog(outcome) {
28972
29024
  `- Repo: \`${outcome.repo}\` · wave ${outcome.wave} · epic ${outcome.epic}`,
28973
29025
  `- Branch: \`${outcome.branch}\` → \`${outcome.epicBranch}\` (${mergeWord(outcome)})`,
28974
29026
  `- Commit: ${outcome.commit ?? "(none)"}`,
28975
- ...outcome.asIs == null ? [] : [`- Developer: **none** — ${AS_IS_MARK}. \`${outcome.branch}\` was settled as it stands, ` + `signed by ${outcome.asIs.actor}: ${outcome.asIs.note}. The Definition of Done below and ` + "the reviewer above judged that branch, unchanged."],
29027
+ ...outcome.asIs == null ? [] : outcome.asIs.reason === "review-only" ? [`- Developer: **none** — ${AS_IS_REVIEW_ONLY_MARK}. \`${outcome.asIs.reviewed?.commit ?? outcome.commit ?? "(none)"}\` ` + `was already on \`${outcome.epicBranch}\` from an earlier turn and nothing had judged it; ` + `signed by ${outcome.asIs.actor}: ${outcome.asIs.note}. The Definition of Done below ran on the ` + "epic head, and the reviewer above judged the range that merge was recorded as."] : [`- Developer: **none** — ${AS_IS_MARK}. \`${outcome.branch}\` was settled as it stands, ` + `signed by ${outcome.asIs.actor}: ${outcome.asIs.note}. The Definition of Done below and ` + "the reviewer above judged that branch, unchanged."],
28976
29028
  ...outcome.developerError === null ? [] : [`- Developer: **FAILED** — ${outcome.developerError}`],
28977
29029
  ...outcome.permissionRefused == null ? [] : [withCure(`- Developer: \`${outcome.permissionRefused}\` was refused for approval by the agent's own ` + "permission layer; the tree held committed work, so the Definition of Done below decided", outcome.permissionRefused, outcome.declaredCommands)],
28978
29030
  ...outcome.budgetDeath == null ? [] : [`- Developer: died on its per-story cap — ${outcome.budgetDeath}; the tree held work, ` + "so the Definition of Done below decided"],
@@ -36574,6 +36626,7 @@ function readReviewLedger(runDir, storyId) {
36574
36626
  blockedWithNothingRun: false,
36575
36627
  reopened: null,
36576
36628
  asIs: null,
36629
+ lastMerge: null,
36577
36630
  fixRound: null,
36578
36631
  formatRetries: 0,
36579
36632
  formatRefusal: null,
@@ -36595,6 +36648,7 @@ function readReviewLedger(runDir, storyId) {
36595
36648
  let lastDodOutputPath = null;
36596
36649
  let reopened = null;
36597
36650
  let asIs = null;
36651
+ let lastMerge = null;
36598
36652
  let fixRound = null;
36599
36653
  let formatRetries = 0;
36600
36654
  let formatRefusal = null;
@@ -36662,6 +36716,13 @@ function readReviewLedger(runDir, storyId) {
36662
36716
  commit2 = payload.commit;
36663
36717
  if (typeof payload.epic_base === "string" && payload.epic_base !== "")
36664
36718
  epicBase = payload.epic_base;
36719
+ if (typeof payload.commit === "string" && payload.commit !== "" && typeof payload.epic_base === "string" && payload.epic_base !== "") {
36720
+ lastMerge = {
36721
+ commit: payload.commit,
36722
+ epicBase: payload.epic_base,
36723
+ verdict: typeof payload.verdict === "string" && payload.verdict !== "" ? payload.verdict : "n-a"
36724
+ };
36725
+ }
36665
36726
  if (payload.status === "done")
36666
36727
  fixRound = null;
36667
36728
  asIs = null;
@@ -36725,6 +36786,7 @@ function readReviewLedger(runDir, storyId) {
36725
36786
  blockedWithNothingRun,
36726
36787
  reopened,
36727
36788
  asIs,
36789
+ lastMerge,
36728
36790
  fixRound,
36729
36791
  formatRetries,
36730
36792
  formatRefusal,
@@ -39394,7 +39456,22 @@ class BuildSession {
39394
39456
  };
39395
39457
  }
39396
39458
  const ahead = await commitsBetween(story.repoDir, story.epicBranch, story.branch);
39397
- if (ahead === null || ahead === 0) {
39459
+ if (ahead === 0) {
39460
+ const merge = readReviewLedger(this.ctx.runDir, id).lastMerge;
39461
+ if (merge !== null && reviewNeverCompleted(merge.verdict)) {
39462
+ return await this.reviewOnlyHalf(planned, asIs, story, before, merge);
39463
+ }
39464
+ return {
39465
+ story,
39466
+ cost: 0,
39467
+ dod: [],
39468
+ commit: null,
39469
+ failure: asIsNotAheadReason(story.branch, story.epicBranch, ahead, merge === null ? undefined : merge),
39470
+ developerError: null,
39471
+ before
39472
+ };
39473
+ }
39474
+ if (ahead === null) {
39398
39475
  return {
39399
39476
  story,
39400
39477
  cost: 0,
@@ -39432,6 +39509,27 @@ class BuildSession {
39432
39509
  }
39433
39510
  return { story, cost: 0, dod, commit: commit2, failure: null, developerError: null, before };
39434
39511
  }
39512
+ async reviewOnlyHalf(planned, asIs, story, before, merge) {
39513
+ const id = planned.story.id;
39514
+ const reviewed = { commit: merge.commit, epicBase: merge.epicBase };
39515
+ this.asIsSettlements.set(id, { ...asIs, reason: "review-only", reviewed });
39516
+ this.lines.push(` · ${id}: ${AS_IS_REVIEW_ONLY_MARK} — \`${merge.commit.slice(0, 7)}\` is already on ` + `\`${story.epicBranch}\` and nothing judged it; running the dod on the epic head and the review ` + `over \`${reviewDiffRange(merge.epicBase, story.epicBranch, story.branch)}\``);
39517
+ await this.writes.run(() => this.refreshStoryBase(planned, story.repoDir, story.worktree, story.branch, story.epicBranch));
39518
+ const dod = await this.runDod(story);
39519
+ if (!this.dodProves(dod)) {
39520
+ const failing = dodFailure(dod);
39521
+ return {
39522
+ story,
39523
+ cost: 0,
39524
+ dod,
39525
+ commit: null,
39526
+ failure: failing === undefined ? "the story declares no dod commands, so nothing could prove it" : dodFailureReason(failing, planned.story.repo),
39527
+ developerError: null,
39528
+ before
39529
+ };
39530
+ }
39531
+ return { story, cost: 0, dod, commit: merge.commit, failure: null, developerError: null, before, reviewOnly: reviewed };
39532
+ }
39435
39533
  async buildHalf(planned) {
39436
39534
  const asIs = this.asIsFor(planned);
39437
39535
  if (asIs !== null)
@@ -39570,6 +39668,10 @@ class BuildSession {
39570
39668
  await this.block(story, "the story produced no commit to review", half.cost, dod);
39571
39669
  return "settled";
39572
39670
  }
39671
+ if (half.reviewOnly !== undefined) {
39672
+ this.noteMerged(story, null);
39673
+ return await this.reviewAndSettle(story, dod, half.reviewOnly.commit, half.cost, null, half.reviewOnly.epicBase);
39674
+ }
39573
39675
  const update = await updateStoryBase({
39574
39676
  storyId: story.planned.story.id,
39575
39677
  repoDir: story.repoDir,
@@ -39654,6 +39756,21 @@ class BuildSession {
39654
39756
  if (review.verdict === "fixlist") {
39655
39757
  const rel = this.writeFixlistFor(story, review, commit2, epicBase);
39656
39758
  const open2 = openFindings(review.fixlist).length;
39759
+ if (open2 === 0) {
39760
+ this.lines.push(` · ${story.planned.story.id}: ${FIXLIST_SETTLED_MARK} (${rel})`);
39761
+ await this.settle(story, "done", {
39762
+ dod,
39763
+ commit: commit2,
39764
+ merged: true,
39765
+ carried,
39766
+ epicBase,
39767
+ verdict: "fixlist",
39768
+ review,
39769
+ cost,
39770
+ reason: null
39771
+ });
39772
+ return "settled";
39773
+ }
39657
39774
  await this.settle(story, "review", {
39658
39775
  dod,
39659
39776
  commit: commit2,
@@ -40456,12 +40573,13 @@ class BuildSession {
40456
40573
  ...outcome.permissionRefused == null ? {} : { permission_refused: outcome.permissionRefused },
40457
40574
  ...outcome.budgetDeath == null ? {} : { budget_death: outcome.budgetDeath },
40458
40575
  ...outcome.reviewerUnfunded == null ? {} : { reviewer_unfunded: outcome.reviewerUnfunded },
40459
- ...outcome.asIs == null ? {} : { as_is: true, as_is_by: outcome.asIs.actor, as_is_note: outcome.asIs.note }
40576
+ ...outcome.asIs == null ? {} : { as_is: true, as_is_by: outcome.asIs.actor, as_is_note: outcome.asIs.note },
40577
+ ...outcome.asIs?.reason === undefined ? {} : { as_is_reason: outcome.asIs.reason }
40460
40578
  });
40461
40579
  if (pruning && (rescued === null || rescued.worktree === null)) {
40462
40580
  await removeWorktree(story.repoDir, story.worktree);
40463
40581
  }
40464
- this.lines.push(` ${status === "done" ? "✓" : "·"} ${id} → \`${status}\`` + (outcome.reason === null ? "" : ` (${outcome.reason})`) + (outcome.asIs == null ? "" : ` — ${AS_IS_MARK}; ${outcome.asIs.actor} signed it`) + (outcome.permissionRefused == null ? "" : ` — ${withCure(`\`${outcome.permissionRefused}\` was refused for approval; the tree held committed work, so the DoD decided`, outcome.permissionRefused, outcome.declaredCommands)}`));
40582
+ this.lines.push(` ${status === "done" ? "✓" : "·"} ${id} → \`${status}\`` + (outcome.reason === null ? "" : ` (${outcome.reason})`) + (outcome.asIs == null ? "" : ` — ${outcome.asIs.reason === "review-only" ? AS_IS_REVIEW_ONLY_MARK : AS_IS_MARK}; ` + `${outcome.asIs.actor} signed it`) + (outcome.permissionRefused == null ? "" : ` — ${withCure(`\`${outcome.permissionRefused}\` was refused for approval; the tree held committed work, so the DoD decided`, outcome.permissionRefused, outcome.declaredCommands)}`));
40465
40583
  }
40466
40584
  writeLog(outcome) {
40467
40585
  const dir = join99(this.ctx.runDir, BUILD_PHASE, LOG_DIR);
@@ -49239,6 +49357,7 @@ function reopenStory(options) {
49239
49357
  ` signed by ${options.actor}: ${options.note}`,
49240
49358
  " no developer will be spawned: the next Build turn runs the dod, the review and the merge " + "over the commits already on the story branch",
49241
49359
  " it is not a shortcut past either gate — a red dod BLOCKS the story, and so does a branch " + "that carries no commit its epic has not already got; the refusal says which",
49360
+ " one named exception: work that is ALREADY on the epic with a review that never completed is " + "settled review-only — nothing merged, the dod on the epic head, the reviewer over the recorded range",
49242
49361
  " the record will say the branch was taken as it stands and name you, so nothing reads as " + "though a developer delivered it",
49243
49362
  ...kept
49244
49363
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tldr-experts",
3
- "version": "0.21.0",
3
+ "version": "0.23.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.21.0",
5
+ "version": "0.23.0",
6
6
  "author": {
7
7
  "name": "Alan Martinez"
8
8
  }