tldr-experts 0.24.0 → 0.25.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,151 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.25.0 — 2026-09-14
4
+
5
+ ### Fixed
6
+
7
+ - **A Watch card refused for an unsourced `## Where` item comes back to its writer, marked, instead
8
+ of being rewritten blind (#301).** MEASURED on two unattended field runs the same night: the
9
+ `05-watch` writer named a PostgreSQL table under `## Where` with no `[src: …]` at all — twice per
10
+ run, on both workspaces, once on two cards in one run — while the migration defining that table
11
+ was cited two lines above under `## Signal`. Three things let it through. The rule said "every
12
+ item is sourced" and listed five source kinds, and nowhere said which kind a table, queue or
13
+ dashboard takes (`stage.md`, the feature brief and the template each showed a file or a fact,
14
+ never a place). The refusal, `no [src: …] token — every item on a card is sourced`, restated the
15
+ rule and named no cure. And the retry re-ran `tldrx next` from scratch: `featurePrompt` took no
16
+ previous-card parameter, the refusal was written to the operator's terminal and never into the
17
+ next prompt, and attempt 2 on run A moved the failure from L21+L22 to L19 — an independent
18
+ re-generation, not a repair. A person hand-edited the card and recorded the stage twice. Now:
19
+ ONE sentence, `NON_FILE_SOURCE_CURE`, says what a place cites (the migration, model or config
20
+ that defines it, or the fact that names it), and the writer's brief teaches it with one worked
21
+ example while the validator's refusal repeats it verbatim — one derivation, so a retry armed with
22
+ the refusal is armed with the cure. And the retry's prompt carries `## Previous attempt` (the
23
+ same heading, H3 and 32 KB cap as the one-agent path, now constants in `prompt.ts`): the card on
24
+ disk re-read by the parser that refused it, each refused line as `L<n> <section>: <message>`
25
+ with the line quoted as written, and the card inlined under "edit, do not restart". Re-validated
26
+ at prompt time rather than replayed from `run.yml`, so a card a person has since fixed shows no
27
+ stale marks; a first attempt has no card and no section, so its prompt is unchanged. The model
28
+ pin stays `haiku`: nothing measured says the model cannot follow the rule — it was never told
29
+ the rule for this case, and the retry was never shown its own card. Pinned by the fake-agent
30
+ harness: the first attempt's refusal names line, rule and cure; the headless retry's and the
31
+ `--prepare` bundle's prompts carry the marked card; a first attempt carries no section.
32
+
33
+ - **The freeze between a release and a merge wave runs both ways now (#304).** #299 made one
34
+ direction mechanical — the wave waits on `.RELEASE-IN-PROGRESS` — and left the other as prose:
35
+ MEASURED by reading `scripts/release.sh` at `a341b4f`, it sourced `merge-lock.sh` only to name
36
+ its own marker, and nothing between its first line and `git commit` read `merge-wave.lock`. A
37
+ release started mid-wave edited CHANGELOG, README and package.json in a tree another process
38
+ was gating, and the only thing that stopped the commit was the ref hook — which aborts the
39
+ COMMIT and leaves the three edits dirty in the shared checkout, with nothing to roll them back.
40
+ `release.sh` now WAITS on a running wave's lock before it writes its marker and before its
41
+ first edit — same `MW_LOCK_*` knobs, same dead-owner rule (a dead wave's lock is broken open
42
+ and said so on stderr) — and gives up with **exit 14**, having edited nothing. The precedence
43
+ is fixed rather than symmetric (peer review of this change): a wave holding the lock finishes,
44
+ never preempted; once the marker is up the release is ahead, a wave that took the lock in the
45
+ gap hands it back as it already did, and the release waits for the lock to clear WITHOUT
46
+ handing back its marker — two sides yielding on the same poll cadence would ping-pong for the
47
+ whole budget and end in 13 and 14; pinned with the gap interleaving built deterministically (a
48
+ stand-in `mv` plants the lock the instant the marker lands). Which makes the wave's dead-owner
49
+ check the only thing that ever clears a marker a SIGKILL or a cut session orphaned (the traps
50
+ cover INT/TERM), so it now says so on stderr, naming the marker and the pid, and that sentence
51
+ is pinned. And a kept marker means two things — queued and untouched, or editing and tagging —
52
+ so it carries `phase: waiting|releasing` (rewritten atomically), which `--status` and the wave's
53
+ refusal print — and while a wave holds the lock, `--status` names the queued release on a
54
+ second line rather than answering "a wave, nothing else": a record never says more than the
55
+ truth (§7). The code: 14 rather than 1 because there is nothing to undo, and
56
+ not the wave's own 6 because the two scripts' codes are read in the same logs, so the next
57
+ number after the wave's 13 keeps a bare "exit 14" unambiguous. Pinned in both directions with
58
+ the real scripts: a real wave against a real running release exits 13, a real release against a
59
+ live wave lock exits 14 with the tree untouched, and a queued release edits nothing until the
60
+ lock is gone. The wave's exit-12 heading gate now also requires the unreleased version to be
61
+ above `package.json`'s — the CHANGELOG used to be checked only against its own top dated
62
+ heading, so a dated section never written, or a heading edited by hand, passed on two lies from
63
+ one file; `package.json` is what shipped (§9's drift guard already pins README's top row to it).
64
+ Same exit 12: one condition, measured twice, and the refusal names both figures. Two Minors the
65
+ #299 review named are pinned rather than fixed, because reading was right: `--status` on an
66
+ old-format lock (owner and token only) with a live pid prints `?` for what it never recorded,
67
+ and `ver_gt` orders `1.0.0 > 0.99.0` and `0.10.0 > 0.9.0` numerically — the function is
68
+ lifted from the script's own text for the test, so the pin is on the real implementation.
69
+
70
+ - **`tldrx next --prepare` and `--commit` ask the same dependency frontier the headless loop asks (#300).**
71
+ MEASURED at `31ed3d7` on the mixed shape #280 opened: a headless pass met S2 behind S1 at `review` and
72
+ — correctly — left S2 `todo`; the host's `--commit --review` blocked S1; the next `--prepare` said
73
+ `prepared S2 · … ($3.20 ceiling, attempt 1 of 1)` and `--commit` then merged S2 into an epic branch
74
+ S1 had put nothing on. `runAll` has asked `blockingDependency` per story since #260/#263, but the
75
+ three in-session doors took `nextPending()` / `inProgress()` as offered, and `pendingStories` skips
76
+ only `done` and a terminal `blocked` — so the `blocked` row #280 stopped writing was, incidentally,
77
+ the only thing keeping `--prepare` off that dependent. A pure in-session run had the hole all along
78
+ (S1 blocked by a verdict, S2 `todo`); the mixed run is what made it fire without a person's hand.
79
+ Now `--prepare` walks the pending stories through the ONE frontier and does per story what the loop
80
+ does — a terminal hold records the dependent `blocked` with the recorded sentence, so the reason
81
+ reaches the gate and `staleDependencyHold` releases it when the dependency lands; a pending hold
82
+ leaves the row `todo` (never `blocked`, which would re-create #280 on the other door) and, with
83
+ nothing else to offer, refuses with exit 1 saying there is nothing to prepare YET and what releases
84
+ it. `--prepare --review` refuses over a hold and records nothing. `--commit` on a story whose
85
+ dependency is no longer `done` refuses with exit 1 in both cases and writes nothing — the bundle,
86
+ the branch and the worktree stay, the story stays `in_progress`, and the sentence names all three
87
+ — because that story has a developer's attempt on its branch and the loop's row would say it had
88
+ none. Exit 1 is the sequencing family (`refusedOnSequence`): the cycle is fine and the fix is the
89
+ other command; a `2` would send the stage back to `ready` and throw away the bundle a held
90
+ `--commit` is sitting on. And the closing hint of a `--commit` with more to do asks the same frontier,
91
+ recording nothing: it used to read raw `nextPending()` and say `S2 is next — run tldrx next --prepare`
92
+ the instant S1 was blocked, naming the one bundle the next `--prepare` refuses to write; it now names
93
+ what `--prepare` would actually offer, or says nothing is next and why. One correction to the issue's
94
+ proposed sequence, measured: S1 at `review`
95
+ followed by `--prepare` does NOT hand S2 out — S1 is pending too and earlier in wave order, so that
96
+ `--prepare` offers S1's review; the dependent is offered the moment S1 leaves `review` for a status
97
+ `pendingStories` skips. Minor by behaviour: a `--prepare` and a `--commit` that used to hand out and
98
+ settle now record `blocked` or refuse.
99
+ - **A `budget raise --stage` typed during a live `run auto` loop no longer evaporates on the loop's
100
+ next save (#305).** MEASURED in the field on 0.21.0: the command printed `04-build/build budget_usd
101
+ $12.60 → $62.60`, exited 0, and two hours later `run.yml` read `budget_usd: 12.60` again with the
102
+ gate note holding the stage on `budget=$35.78 of $12.60` — the developers had been capped on the
103
+ figure the operator had raised. Not a `budget` bug: a LOST UPDATE on `run.yml`. `RunStore.save()`
104
+ re-read `budget.yml`'s ceilings from disk before writing (the 2026-08-29 fix) and wrote `run.yml`
105
+ WHOLE from the copy the store loaded — and `run auto` holds one store for the whole of a stage, the
106
+ Build fan-out included. Pinned RED through the real writers on today's `main`: the raise reverted
107
+ (`Expected: 54 / Received: 4`), a `reject --and-continue` beside a held store reverted (`rejected` →
108
+ `pending`), a `run cancel` beside one lost its `cancelled:` block. `save()` now re-reads `run.yml`
109
+ under the workspace lock and carries over it ONLY the fields this store changed since it last read
110
+ or wrote the file; every derived figure is rolled up from the merged document. That is ownership by
111
+ change, not the blind reload-and-merge the issue warned against: every value on disk was written by
112
+ the store that changed it, and a store never writes back a value it merely loaded — so the loop,
113
+ which never touches a stage's `budget_usd`, cannot revert a raise, and the raise, which never touches
114
+ task rows, cannot revert the ledger. Who changes what is now written down in spec §2.2 — the loop
115
+ owns the execution record, a person's commands own `budget_usd`, `cancelled`, gate decisions and the
116
+ policies — and the one field two writers can both change, a stage's `status` under `run cancel
117
+ --force` while a loop still runs it, has a DECLARED winner (peer review): `cancelled` is terminal
118
+ and wins. A cancel that landed under a held store keeps its statuses and the cursor on the file —
119
+ the held store's task rows still land, those turns happened and cost money — the save answers
120
+ `cancelledUnder: true` and says so on stderr, and the PROCESS obeys the file, not only the file:
121
+ the Build executor asks `RunStore.cancelledOnDisk` before every spawn (each story attempt, each
122
+ parallel lane, the reviewer) and `tldrx next` stops after the save that merged the cancel, with
123
+ the same exit 0 its "is cancelled — nothing to advance" path uses, rather than parking a
124
+ cancelled stage on a gate. Pinned through the real dispatch with the fake agent: a forced cancel
125
+ fired from S1's own DoD command leaves exactly one `agent.spawned` on the log — S1's developer —
126
+ S1 parked at `review` with an `n-a` verdict naming the cancel, S2 never started, `run.yml`
127
+ `cancelled` with the stage `cancelled`. MEASURED before the rule: the same collision spawned five
128
+ times over an already-cancelled run (S1's reviewer, S2's developer and reviewer, S1's developer
129
+ again), and the file ended `cancelled` with the stage `awaiting_gate` — #305's shape one level
130
+ down. A verdict read across two owners (the loop's spend against an
131
+ operator's ceiling) already records the figures it read beside itself — `budget.blocked` carries
132
+ `remaining_usd` and `ceiling_usd`, the auto-gate note carries `budget=$x of $y` — and that is now
133
+ the stated rule, because the file can legitimately hold a pair no single writer saw together. For
134
+ the record: `reject` had no live exposure by construction (it refuses anything but
135
+ `awaiting_gate`/`failed`, by which point the loop's store for that stage is gone) and `run cancel`
136
+ already refused under a live `.lock` unless forced; both are pinned at store level anyway, because
137
+ the next writer will not be one of these three.
138
+ Two edges from the pre-merge review, because a fallback that guesses silently is the same lie in a
139
+ smaller font: a `run.yml` on disk that exists but cannot be merged with (does not parse, does not
140
+ validate) has the in-memory copy written whole — the only honest move — but that is exactly the
141
+ case an external write may just have been destroyed, so `save()` says so on stderr, naming the
142
+ reason and the `.bak` beside the file where the replaced version is (the convention the repair path
143
+ already uses; no new event kind, none exists for repairs and §7 does not invent one); an absent file
144
+ is merged with nothing and stays silent, there was nothing to lose. And a `run.yml` that records
145
+ ANOTHER run is not damage but a violated premise: the save REFUSES, naming both ids and the path,
146
+ and writes nothing — unreachable by path construction today, pinned so it stays unreachable by
147
+ accident.
148
+
3
149
  ## 0.24.0 — 2026-09-14
4
150
 
5
151
  ### Added
package/README.md CHANGED
@@ -335,6 +335,7 @@ back on the registry is 0.3.0.
335
335
 
336
336
  | Version | Date | Status | Contains |
337
337
  |---|---|---|---|
338
+ | 0.25.0 | 2026-09-14 | `beta` | The other half of the two-session protocol and four holes measured on live runs. `scripts/release.sh` now waits on a running wave's lock before touching the tree and never hands back the marker it wrote — release has precedence, the wave yields, exit 14 when it gives up; the heading gate also refuses an unreleased version at or below `package.json`; the marker carries its phase and `--status` reports a release queued behind a wave (#304). In-session `tldrx next --prepare` and `--commit` ask the same dependency frontier the headless loop asks: a terminal hold records the dependent `blocked`, a pending hold refuses without writing, a held `--commit` keeps the work it sits on and says where it is (#300). A Watch card refused for an unsourced `## Where` item comes back to its writer marked, the refusal names the cure, and the brief shows one non-file example, so a retry edits the card instead of regenerating it blind (#301). And `run.yml` no longer loses an external write to a live loop: a save carries only the fields the store changed over a fresh read, ownership is declared, `cancelled` is terminal and wins in the file and in the process, a file that cannot be merged is kept beside as `.bak` and said so, and a file recording another run is refused (#305). Minor release: a new exit code on a public script, a stricter merge gate, and `--prepare`/`--commit` refuse where they used to proceed. |
338
339
  | 0.24.0 | 2026-09-14 | `beta` | Five fixes from one unattended night, all measured on live runs. The two-session protocol is mechanical where it was chat: the merge wave refuses a merged tree with two unreleased CHANGELOG headings or one at or below the last release (exit 12), `release.sh` holds a `.RELEASE-IN-PROGRESS` marker the wave waits on (exit 13 when it gives up), `merge-wave.sh --status` reads holder, branch and phase in one line, and the `maintain` skill gains a worker-mode section (#299). A per-story cap says what it was derived from — plan price × stage scale — and names the lever that moves it, and a plan whose prices exceed the stage budget is told so instead of being scaled in silence (#281). A story whose dependency is still at `review` waits in `todo` instead of being written `blocked`, and a dependency block left by an earlier version is released once the dependency is `done` (#280). `--until-done` compares the refusal across attempts, not the advice line printed under it, so a failure that made progress keeps its relaunches (#297). And the Build handoff no longer fails its own `claim-sources` check over a refused command that spans lines: one physical line per element, the break shown as `⏎` and explained once in the document (#283). Minor release: the wave and `--until-done` behave differently in situations that exist today. |
339
340
  | 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. |
340
341
  | 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. |
@@ -22,7 +22,7 @@ import {
22
22
  validateRunBudget,
23
23
  wouldExceed,
24
24
  wouldExceedHostTokens
25
- } from "./chunk-2jb272zj.js";
25
+ } from "./chunk-bbrhs055.js";
26
26
  import {
27
27
  EventLog
28
28
  } from "./chunk-6z5rmj0b.js";
@@ -706,6 +706,9 @@ function stackExpertNames(root, repos) {
706
706
  return names;
707
707
  }
708
708
 
709
+ // src/core/facilitator/prompt.ts
710
+ var MAX_PREVIOUS_ATTEMPT_BYTES = 32 * 1024;
711
+
709
712
  // src/core/text/srcGrammarContract.ts
710
713
  var KINDS = {
711
714
  file: { shape: "`[repo:]path:line[-line]`", example: "api:src/Selector.ts:241" },
@@ -8,7 +8,7 @@ import {
8
8
  spentBasis,
9
9
  tallyOf,
10
10
  validateRunBudget
11
- } from "./chunk-2jb272zj.js";
11
+ } from "./chunk-bbrhs055.js";
12
12
  import {
13
13
  EventLog,
14
14
  OUTCOME_NOT_RECORDED,
@@ -56,6 +56,7 @@ import { join as join4 } from "node:path";
56
56
 
57
57
  // src/core/run/RunStore.ts
58
58
  import { existsSync, readFileSync as readFileSync2 } from "node:fs";
59
+ import { isDeepStrictEqual } from "node:util";
59
60
  import { basename, join as join2 } from "node:path";
60
61
 
61
62
  // src/core/frameworkVersion.ts
@@ -297,11 +298,14 @@ class RunStore {
297
298
  events;
298
299
  current;
299
300
  currentBudget;
301
+ loaded;
302
+ cancelledUnderFlag = false;
300
303
  budgetMutated = false;
301
304
  constructor(runDir, run, budget, events) {
302
305
  this.runDir = runDir;
303
306
  this.events = events;
304
307
  this.current = run;
308
+ this.loaded = run;
305
309
  this.currentBudget = budget;
306
310
  }
307
311
  static open(runDir) {
@@ -386,6 +390,20 @@ class RunStore {
386
390
  get runId() {
387
391
  return this.current.run;
388
392
  }
393
+ get cancelledUnder() {
394
+ return this.cancelledUnderFlag;
395
+ }
396
+ static cancelledOnDisk(runDir) {
397
+ const path = join2(runDir, "run.yml");
398
+ if (!existsSync(path))
399
+ return false;
400
+ try {
401
+ const doc = parseYaml(readFileSync2(path, "utf8"));
402
+ return validateRunFile(doc).ok && asRunFile(doc).cancelled !== undefined;
403
+ } catch {
404
+ return false;
405
+ }
406
+ }
389
407
  cursorEntry() {
390
408
  return stageAt(this.current, this.current.cursor);
391
409
  }
@@ -407,13 +425,14 @@ class RunStore {
407
425
  this.events.append(event);
408
426
  }
409
427
  save() {
410
- const rolled = rollUp(this.current);
411
- const runValidation = validateRunFile(rolled);
412
- if (!runValidation.ok) {
413
- const first = runValidation.issues[0];
414
- throw new RunStoreError(`refusing to write an invalid run.yml: ${first?.path ?? ""} ${first?.message ?? ""}`);
415
- }
416
- withWorkspaceLock(workspaceRootOfRunDir(this.runDir), () => {
428
+ return withWorkspaceLock(workspaceRootOfRunDir(this.runDir), () => {
429
+ const base = this.runToWrite();
430
+ const rolled = rollUp(base.run);
431
+ const runValidation = validateRunFile(rolled);
432
+ if (!runValidation.ok) {
433
+ const first = runValidation.issues[0];
434
+ throw new RunStoreError(`refusing to write an invalid run.yml: ${first?.path ?? ""} ${first?.message ?? ""}`);
435
+ }
417
436
  const budget = rollUpBudget(this.ceilingsToWrite(), rolled);
418
437
  const budgetValidation = validateRunBudget(budget);
419
438
  if (!budgetValidation.ok) {
@@ -421,12 +440,56 @@ class RunStore {
421
440
  throw new RunStoreError(`refusing to write an invalid budget.yml: ${first?.path ?? ""} ${first?.message ?? ""}`);
422
441
  }
423
442
  writeAtomic(join2(this.runDir, "budget.yml"), emitBudgetYaml(budget));
424
- writeAtomic(join2(this.runDir, "run.yml"), emitRunYaml(rolled));
443
+ const runPath = join2(this.runDir, "run.yml");
444
+ writeAtomic(runPath, emitRunYaml(rolled));
425
445
  this.current = rolled;
446
+ this.loaded = rolled;
426
447
  this.currentBudget = budget;
427
448
  this.budgetMutated = false;
449
+ if (base.fallback !== null) {
450
+ process.stderr.write(`tldrx: ${runPath} ${base.fallback}, so it could not be merged with: this process's copy of ` + `the run was written whole, and the version that was on disk is beside it as ${backupPathFor(runPath)}. ` + "A change another command wrote there since this process read the run (a budget raise, a " + `cancel, a rejection) may be in that backup and not in run.yml.
451
+ `);
452
+ }
453
+ if (base.cancelledUnder) {
454
+ this.cancelledUnderFlag = true;
455
+ const who = rolled.cancelled;
456
+ process.stderr.write(`tldrx: run ${rolled.run} was cancelled` + (who === undefined ? "" : ` by ${who.by} at ${who.at} (${who.note})`) + " while this process held it: the cancel's statuses were kept, this process's task rows were " + `recorded beside them, and nothing more may be spawned for it.
457
+ `);
458
+ }
459
+ return { cancelledUnder: base.cancelledUnder };
428
460
  });
429
461
  }
462
+ runToWrite() {
463
+ const path = join2(this.runDir, "run.yml");
464
+ if (!existsSync(path))
465
+ return { run: this.current, fallback: null, cancelledUnder: false };
466
+ let doc;
467
+ try {
468
+ doc = parseYaml(readFileSync2(path, "utf8"));
469
+ } catch (error) {
470
+ const detail = error instanceof Error ? error.message : String(error);
471
+ return { run: this.current, fallback: `could not be read or parsed (${detail})`, cancelledUnder: false };
472
+ }
473
+ const validation = validateRunFile(doc);
474
+ if (!validation.ok) {
475
+ const first = validation.issues[0];
476
+ return {
477
+ run: this.current,
478
+ fallback: `does not validate (${first?.path ?? ""} ${first?.message ?? "schema error"})`,
479
+ cancelledUnder: false
480
+ };
481
+ }
482
+ const onDisk = asRunFile(doc);
483
+ if (onDisk.run !== this.current.run) {
484
+ throw new RunStoreError(`refusing to write ${path}: it records run ${onDisk.run}, and this store holds run ` + `${this.current.run} — a save never overwrites another run's record`);
485
+ }
486
+ const cancelledUnder = onDisk.cancelled !== undefined && this.loaded.cancelled === undefined && this.current.cancelled === undefined;
487
+ return {
488
+ run: carryChanges(onDisk, this.current, this.loaded, { cancelledWins: this.cancelledUnderFlag || cancelledUnder }),
489
+ fallback: null,
490
+ cancelledUnder
491
+ };
492
+ }
430
493
  ceilingsToWrite() {
431
494
  if (this.budgetMutated)
432
495
  return this.currentBudget;
@@ -474,6 +537,41 @@ function parseStateFile(path) {
474
537
  `));
475
538
  }
476
539
  }
540
+ function carryChanges(onDisk, mine, loaded, rules = { cancelledWins: false }) {
541
+ const top = carryLevel(onDisk, mine, loaded, rules.cancelledWins ? ["phases", "cursor"] : ["phases"]);
542
+ const phases = mine.phases.map((phase) => {
543
+ const theirs = onDisk.phases.find((p) => p.id === phase.id);
544
+ const was = loaded.phases.find((p) => p.id === phase.id);
545
+ if (theirs === undefined || was === undefined)
546
+ return phase;
547
+ const stages = phase.stages.map((stage2) => {
548
+ const theirStage = theirs.stages.find((s) => s.id === stage2.id);
549
+ const wasStage = was.stages.find((s) => s.id === stage2.id);
550
+ if (theirStage === undefined || wasStage === undefined)
551
+ return stage2;
552
+ const kept = rules.cancelledWins && theirStage.status === "cancelled" ? ["status", "started_at", "ended_at"] : [];
553
+ return carryLevel(theirStage, stage2, wasStage, kept);
554
+ });
555
+ return { ...carryLevel(theirs, phase, was, ["stages"]), stages };
556
+ });
557
+ return { ...top, phases };
558
+ }
559
+ function carryLevel(onDisk, mine, loaded, skip) {
560
+ const out = { ...onDisk };
561
+ const theirs = mine;
562
+ const was = loaded;
563
+ for (const key of new Set([...Object.keys(theirs), ...Object.keys(was)])) {
564
+ if (skip.includes(key))
565
+ continue;
566
+ if (isDeepStrictEqual(theirs[key], was[key]))
567
+ continue;
568
+ if (theirs[key] === undefined)
569
+ delete out[key];
570
+ else
571
+ out[key] = theirs[key];
572
+ }
573
+ return out;
574
+ }
477
575
  function rollUp(run) {
478
576
  const phases = run.phases.map((phase) => {
479
577
  const stages = phase.stages.map((stage2) => ({
@@ -20,14 +20,14 @@ import {
20
20
  runSnapshot,
21
21
  statusWithOutcome,
22
22
  whatIsWaiting
23
- } from "./chunk-4frmcwp3.js";
23
+ } from "./chunk-rgn23z77.js";
24
24
  import {
25
25
  expertsDir,
26
26
  loadExperts,
27
27
  pathsIntersect,
28
28
  readExpertDomain,
29
29
  stackExpertNames
30
- } from "./chunk-2jb272zj.js";
30
+ } from "./chunk-bbrhs055.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-4frmcwp3.js";
6
- import"./chunk-2jb272zj.js";
5
+ } from "./chunk-rgn23z77.js";
6
+ import"./chunk-bbrhs055.js";
7
7
  import"./chunk-6z5rmj0b.js";
8
8
  import"./chunk-m1s8a6s0.js";
9
9
  import"./chunk-yre2scxn.js";
package/dist/tldrx.js CHANGED
@@ -17140,6 +17140,7 @@ import { basename as basename4 } from "node:path";
17140
17140
 
17141
17141
  // src/core/run/RunStore.ts
17142
17142
  import { existsSync as existsSync12, readFileSync as readFileSync12 } from "node:fs";
17143
+ import { isDeepStrictEqual } from "node:util";
17143
17144
  import { basename as basename3, join as join14 } from "node:path";
17144
17145
 
17145
17146
  // src/hooks/lib/actor.ts
@@ -17160,11 +17161,14 @@ class RunStore {
17160
17161
  events;
17161
17162
  current;
17162
17163
  currentBudget;
17164
+ loaded;
17165
+ cancelledUnderFlag = false;
17163
17166
  budgetMutated = false;
17164
17167
  constructor(runDir, run, budget, events) {
17165
17168
  this.runDir = runDir;
17166
17169
  this.events = events;
17167
17170
  this.current = run;
17171
+ this.loaded = run;
17168
17172
  this.currentBudget = budget;
17169
17173
  }
17170
17174
  static open(runDir) {
@@ -17249,6 +17253,20 @@ class RunStore {
17249
17253
  get runId() {
17250
17254
  return this.current.run;
17251
17255
  }
17256
+ get cancelledUnder() {
17257
+ return this.cancelledUnderFlag;
17258
+ }
17259
+ static cancelledOnDisk(runDir) {
17260
+ const path = join14(runDir, "run.yml");
17261
+ if (!existsSync12(path))
17262
+ return false;
17263
+ try {
17264
+ const doc = parseYaml(readFileSync12(path, "utf8"));
17265
+ return validateRunFile(doc).ok && asRunFile(doc).cancelled !== undefined;
17266
+ } catch {
17267
+ return false;
17268
+ }
17269
+ }
17252
17270
  cursorEntry() {
17253
17271
  return stageAt(this.current, this.current.cursor);
17254
17272
  }
@@ -17270,13 +17288,14 @@ class RunStore {
17270
17288
  this.events.append(event2);
17271
17289
  }
17272
17290
  save() {
17273
- const rolled = rollUp(this.current);
17274
- const runValidation = validateRunFile(rolled);
17275
- if (!runValidation.ok) {
17276
- const first = runValidation.issues[0];
17277
- throw new RunStoreError(`refusing to write an invalid run.yml: ${first?.path ?? ""} ${first?.message ?? ""}`);
17278
- }
17279
- withWorkspaceLock(workspaceRootOfRunDir(this.runDir), () => {
17291
+ return withWorkspaceLock(workspaceRootOfRunDir(this.runDir), () => {
17292
+ const base = this.runToWrite();
17293
+ const rolled = rollUp(base.run);
17294
+ const runValidation = validateRunFile(rolled);
17295
+ if (!runValidation.ok) {
17296
+ const first = runValidation.issues[0];
17297
+ throw new RunStoreError(`refusing to write an invalid run.yml: ${first?.path ?? ""} ${first?.message ?? ""}`);
17298
+ }
17280
17299
  const budget = rollUpBudget(this.ceilingsToWrite(), rolled);
17281
17300
  const budgetValidation = validateRunBudget(budget);
17282
17301
  if (!budgetValidation.ok) {
@@ -17284,12 +17303,56 @@ class RunStore {
17284
17303
  throw new RunStoreError(`refusing to write an invalid budget.yml: ${first?.path ?? ""} ${first?.message ?? ""}`);
17285
17304
  }
17286
17305
  writeAtomic(join14(this.runDir, "budget.yml"), emitBudgetYaml(budget));
17287
- writeAtomic(join14(this.runDir, "run.yml"), emitRunYaml(rolled));
17306
+ const runPath = join14(this.runDir, "run.yml");
17307
+ writeAtomic(runPath, emitRunYaml(rolled));
17288
17308
  this.current = rolled;
17309
+ this.loaded = rolled;
17289
17310
  this.currentBudget = budget;
17290
17311
  this.budgetMutated = false;
17312
+ if (base.fallback !== null) {
17313
+ process.stderr.write(`tldrx: ${runPath} ${base.fallback}, so it could not be merged with: this process's copy of ` + `the run was written whole, and the version that was on disk is beside it as ${backupPathFor(runPath)}. ` + "A change another command wrote there since this process read the run (a budget raise, a " + `cancel, a rejection) may be in that backup and not in run.yml.
17314
+ `);
17315
+ }
17316
+ if (base.cancelledUnder) {
17317
+ this.cancelledUnderFlag = true;
17318
+ const who = rolled.cancelled;
17319
+ process.stderr.write(`tldrx: run ${rolled.run} was cancelled` + (who === undefined ? "" : ` by ${who.by} at ${who.at} (${who.note})`) + " while this process held it: the cancel's statuses were kept, this process's task rows were " + `recorded beside them, and nothing more may be spawned for it.
17320
+ `);
17321
+ }
17322
+ return { cancelledUnder: base.cancelledUnder };
17291
17323
  });
17292
17324
  }
17325
+ runToWrite() {
17326
+ const path = join14(this.runDir, "run.yml");
17327
+ if (!existsSync12(path))
17328
+ return { run: this.current, fallback: null, cancelledUnder: false };
17329
+ let doc;
17330
+ try {
17331
+ doc = parseYaml(readFileSync12(path, "utf8"));
17332
+ } catch (error) {
17333
+ const detail = error instanceof Error ? error.message : String(error);
17334
+ return { run: this.current, fallback: `could not be read or parsed (${detail})`, cancelledUnder: false };
17335
+ }
17336
+ const validation = validateRunFile(doc);
17337
+ if (!validation.ok) {
17338
+ const first = validation.issues[0];
17339
+ return {
17340
+ run: this.current,
17341
+ fallback: `does not validate (${first?.path ?? ""} ${first?.message ?? "schema error"})`,
17342
+ cancelledUnder: false
17343
+ };
17344
+ }
17345
+ const onDisk = asRunFile(doc);
17346
+ if (onDisk.run !== this.current.run) {
17347
+ throw new RunStoreError(`refusing to write ${path}: it records run ${onDisk.run}, and this store holds run ` + `${this.current.run} — a save never overwrites another run's record`);
17348
+ }
17349
+ const cancelledUnder = onDisk.cancelled !== undefined && this.loaded.cancelled === undefined && this.current.cancelled === undefined;
17350
+ return {
17351
+ run: carryChanges(onDisk, this.current, this.loaded, { cancelledWins: this.cancelledUnderFlag || cancelledUnder }),
17352
+ fallback: null,
17353
+ cancelledUnder
17354
+ };
17355
+ }
17293
17356
  ceilingsToWrite() {
17294
17357
  if (this.budgetMutated)
17295
17358
  return this.currentBudget;
@@ -17337,6 +17400,41 @@ function parseStateFile(path) {
17337
17400
  `));
17338
17401
  }
17339
17402
  }
17403
+ function carryChanges(onDisk, mine, loaded, rules = { cancelledWins: false }) {
17404
+ const top = carryLevel(onDisk, mine, loaded, rules.cancelledWins ? ["phases", "cursor"] : ["phases"]);
17405
+ const phases = mine.phases.map((phase) => {
17406
+ const theirs = onDisk.phases.find((p) => p.id === phase.id);
17407
+ const was = loaded.phases.find((p) => p.id === phase.id);
17408
+ if (theirs === undefined || was === undefined)
17409
+ return phase;
17410
+ const stages = phase.stages.map((stage2) => {
17411
+ const theirStage = theirs.stages.find((s) => s.id === stage2.id);
17412
+ const wasStage = was.stages.find((s) => s.id === stage2.id);
17413
+ if (theirStage === undefined || wasStage === undefined)
17414
+ return stage2;
17415
+ const kept = rules.cancelledWins && theirStage.status === "cancelled" ? ["status", "started_at", "ended_at"] : [];
17416
+ return carryLevel(theirStage, stage2, wasStage, kept);
17417
+ });
17418
+ return { ...carryLevel(theirs, phase, was, ["stages"]), stages };
17419
+ });
17420
+ return { ...top, phases };
17421
+ }
17422
+ function carryLevel(onDisk, mine, loaded, skip) {
17423
+ const out = { ...onDisk };
17424
+ const theirs = mine;
17425
+ const was = loaded;
17426
+ for (const key of new Set([...Object.keys(theirs), ...Object.keys(was)])) {
17427
+ if (skip.includes(key))
17428
+ continue;
17429
+ if (isDeepStrictEqual(theirs[key], was[key]))
17430
+ continue;
17431
+ if (theirs[key] === undefined)
17432
+ delete out[key];
17433
+ else
17434
+ out[key] = theirs[key];
17435
+ }
17436
+ return out;
17437
+ }
17340
17438
  function rollUp(run) {
17341
17439
  const phases = run.phases.map((phase) => {
17342
17440
  const stages = phase.stages.map((stage2) => ({
@@ -25206,6 +25304,8 @@ function renderStagePreamble(preamble) {
25206
25304
  var QUESTIONS_FILE2 = "questions.md";
25207
25305
  var INPUTS_HEADING = "Inputs";
25208
25306
  var PREVIOUS_ATTEMPT_HEADING = "Previous attempt";
25307
+ var PREVIOUS_ATTEMPT_EDIT_HEADING = "Previous attempt — edit, do not restart";
25308
+ var MAX_PREVIOUS_ATTEMPT_BYTES = 32 * 1024;
25209
25309
  function buildPrompt(parts) {
25210
25310
  return renderParts(parts).map((part) => part.text).join("");
25211
25311
  }
@@ -34508,94 +34608,6 @@ function quoted(values) {
34508
34608
  // src/core/watch/watchPrompt.ts
34509
34609
  import { existsSync as existsSync66, readFileSync as readFileSync60 } from "node:fs";
34510
34610
  import { join as join80 } from "node:path";
34511
- var WATCH_FACT_AREAS = ["observability", "deploy", "ownership"];
34512
- function featureInputs(options) {
34513
- const inputs = [];
34514
- for (const story of options.feature.stories) {
34515
- inputs.push({ path: story.path, content: story.text });
34516
- }
34517
- const epicPath = `${PLAN_PHASE2}/${EPICS_DIR}/${options.feature.epicId}.md`;
34518
- const epicAbs = join80(options.runDir, epicPath);
34519
- if (existsSync66(epicAbs))
34520
- inputs.push({ path: epicPath, content: readFileSync60(epicAbs, "utf8") });
34521
- inputs.push({ path: `(git) ${options.feature.epicId} branch vs default branch`, content: renderDiffs(options.diffs) });
34522
- inputs.push({ path: `(facts) area: ${WATCH_FACT_AREAS.join(", ")}`, content: renderWatchFacts(options.facts, options.feature.repos) });
34523
- for (const repo of options.feature.repos) {
34524
- const rel = `${PROJECT_FRAMEWORK_DIR}/map/${repo}/gotchas.md`;
34525
- const abs = join80(options.root, rel);
34526
- if (existsSync66(abs))
34527
- inputs.push({ path: rel, content: readFileSync60(abs, "utf8") });
34528
- }
34529
- return inputs.slice(0, MAX_STAGE_INPUTS);
34530
- }
34531
- function renderWatchFacts(facts, repos) {
34532
- const areas = new Set(WATCH_FACT_AREAS);
34533
- const relevant = facts.filter((fact) => isLive(fact) && areas.has(fact.area) && (fact.repos.length === 0 || fact.repos.some((r) => repos.includes(r))));
34534
- if (relevant.length === 0) {
34535
- return `_No live fact is tagged ${WATCH_FACT_AREAS.join(" or ")} for these repos. ` + "Cite `absent:.tldrx/memory/facts.yml` rather than inventing where a signal is read._";
34536
- }
34537
- return relevant.map((fact) => `- [${fact.id}] ${fact.fact} (${fact.area} · ${fact.confidence})`).join(`
34538
- `);
34539
- }
34540
- function watcherRelPath(featureId2) {
34541
- return `${WATCH_PHASE}/${WATCHERS_DIR}/${featureId2}.md`;
34542
- }
34543
- function featureBrief(feature) {
34544
- const path = watcherRelPath(feature.id);
34545
- const stories = feature.stories.map((s) => s.story.id);
34546
- return [
34547
- `Write exactly ONE file: \`${path}\`. Do not write, edit or delete anything else.`,
34548
- "",
34549
- "Its YAML front matter is fixed — copy it verbatim:",
34550
- "",
34551
- "```yaml",
34552
- "---",
34553
- "version: 1",
34554
- `id: ${feature.id}`,
34555
- `epic: ${feature.epicId}`,
34556
- `title: ${JSON.stringify(feature.title)}`,
34557
- `stories: [${stories.join(", ")}]`,
34558
- `repos: [${feature.repos.join(", ")}]`,
34559
- "status: draft",
34560
- "---",
34561
- "```",
34562
- "",
34563
- `Then these H2 sections, in this order: ${WATCHER_SECTIONS.map((s) => `\`## ${s}\``).join(" · ")}.`,
34564
- "",
34565
- "- Every list item under **Signal**, **Where**, **Healthy baseline** and **Looks broken when**",
34566
- " ends with a `[src: …]` token: `<repo>:<path>:<line>` for a line in the built code, `F<n>` for",
34567
- " a recorded fact, or `absent:<what you looked at>` when the code emits nothing.",
34568
- "- **Signal** names the log line, metric or event that is IN the diff above, at the line it is on.",
34569
- " If nothing is emitted, say so with an `absent:` source and say what to instrument. Do not",
34570
- " describe a signal that would be nice to have as though it exists.",
34571
- "- **Query** is one fenced block, copy-pasteable in whatever place **Where** names.",
34572
- " If — and ONLY if — the code emits nothing at all, so there is no place to paste a query into,",
34573
- " write ONE line instead of the block, sourced like every other claim on the card:",
34574
- "",
34575
- " Query: none — <why nothing is queryable> [src: absent:<what you looked at>]",
34576
- "",
34577
- " This is CHECKED, not trusted: it is refused unless **Signal** above cites `absent:` and the",
34578
- " reason's own source is `absent:` too. A card that names a real signal has somewhere to point a",
34579
- " query at, so `none` on one is a shortcut and will fail the stage. An unsourced `none` is refused",
34580
- " exactly like an unsourced bullet, and describing a query in prose is still refused either way.",
34581
- "- **Sources** is prose: each citation above, once, with what it establishes.",
34582
- "",
34583
- "Owner (optional, gh #70). A Signal item may name WHO to ask about it, as `(owner: <name>)`",
34584
- "placed BEFORE its `[src: …]` token — the token is still the last thing on the line:",
34585
- "",
34586
- " - `checkout.completed` is written on every order (owner: alice) [src: api:src/Checkout.cs:88]",
34587
- "",
34588
- "Write it ONLY from an `ownership` fact inlined above, and write the SAME name that fact uses.",
34589
- "Put it in the front matter as `owner: <name>` when one name covers every item on the card, or on",
34590
- "the individual items when they differ. Do not invent a name, do not put a repo name there (the",
34591
- "framework already derives that from your citation), and leave it off entirely when no fact says.",
34592
- "An `(owner: )` with nothing in it is refused — it loses the name it was trying to write.",
34593
- "",
34594
- "Leave `status: draft`. The framework sets it: a card is stamped `verified` only when nothing",
34595
- "under **Signal** cites `absent:`. Writing `verified` yourself changes nothing and will be overwritten."
34596
- ].join(`
34597
- `);
34598
- }
34599
34611
 
34600
34612
  // src/core/watch/itemOwner.ts
34601
34613
  var MAX_OWNER_CHARS = 64;
@@ -34632,7 +34644,8 @@ function itemOwner(text3) {
34632
34644
  }
34633
34645
 
34634
34646
  // src/core/watch/watcherFile.ts
34635
- var NO_SRC_TOKEN_ISSUE = "no `[src: …]` token — every item on a card is sourced";
34647
+ var NON_FILE_SOURCE_CURE = "a table, queue, dashboard or console is not a line of code, so cite the file that defines it " + "(the migration, model or config, as `<repo>:<path>:<line>`) or the `F<n>` fact that names it";
34648
+ var NO_SRC_TOKEN_ISSUE = `no \`[src: …]\` token — every item on a card is sourced; ${NON_FILE_SOURCE_CURE}`;
34636
34649
  var QUERY_NONE_NOT_EARNED_ISSUE = "`Query: none` is only for a card whose `## Signal` is itself `absent:` — this one names a real signal";
34637
34650
  var QUERY_NOT_PASTEABLE_ISSUE = "`## Query` holds no fenced block — the query has to be copy-pasteable, not described";
34638
34651
  var SRC_SECTION2 = "Evidence ledger";
@@ -34820,12 +34833,147 @@ function setWatcherStatus(text3, status) {
34820
34833
  }
34821
34834
  return text3;
34822
34835
  }
34836
+ function describeWatcherIssue(issue) {
34837
+ return `L${String(issue.line)}${issue.path === "" ? "" : ` ${issue.path}`}: ${issue.message}`;
34838
+ }
34823
34839
  function describeWatcherIssues(issues, max = 5) {
34824
- const shown = issues.slice(0, max).map((issue) => ` L${String(issue.line)}${issue.path === "" ? "" : ` ${issue.path}`}: ${issue.message}`);
34840
+ const shown = issues.slice(0, max).map((issue) => ` ${describeWatcherIssue(issue)}`);
34825
34841
  const rest = issues.length - shown.length;
34826
34842
  return rest > 0 ? [...shown, ` (+${String(rest)} more)`] : shown;
34827
34843
  }
34828
34844
 
34845
+ // src/core/watch/watchPrompt.ts
34846
+ var WATCH_FACT_AREAS = ["observability", "deploy", "ownership"];
34847
+ function featureInputs(options) {
34848
+ const inputs = [];
34849
+ for (const story of options.feature.stories) {
34850
+ inputs.push({ path: story.path, content: story.text });
34851
+ }
34852
+ const epicPath = `${PLAN_PHASE2}/${EPICS_DIR}/${options.feature.epicId}.md`;
34853
+ const epicAbs = join80(options.runDir, epicPath);
34854
+ if (existsSync66(epicAbs))
34855
+ inputs.push({ path: epicPath, content: readFileSync60(epicAbs, "utf8") });
34856
+ inputs.push({ path: `(git) ${options.feature.epicId} branch vs default branch`, content: renderDiffs(options.diffs) });
34857
+ inputs.push({ path: `(facts) area: ${WATCH_FACT_AREAS.join(", ")}`, content: renderWatchFacts(options.facts, options.feature.repos) });
34858
+ for (const repo of options.feature.repos) {
34859
+ const rel = `${PROJECT_FRAMEWORK_DIR}/map/${repo}/gotchas.md`;
34860
+ const abs = join80(options.root, rel);
34861
+ if (existsSync66(abs))
34862
+ inputs.push({ path: rel, content: readFileSync60(abs, "utf8") });
34863
+ }
34864
+ return inputs.slice(0, MAX_STAGE_INPUTS);
34865
+ }
34866
+ function renderWatchFacts(facts, repos) {
34867
+ const areas = new Set(WATCH_FACT_AREAS);
34868
+ const relevant = facts.filter((fact) => isLive(fact) && areas.has(fact.area) && (fact.repos.length === 0 || fact.repos.some((r) => repos.includes(r))));
34869
+ if (relevant.length === 0) {
34870
+ return `_No live fact is tagged ${WATCH_FACT_AREAS.join(" or ")} for these repos. ` + "Cite `absent:.tldrx/memory/facts.yml` rather than inventing where a signal is read._";
34871
+ }
34872
+ return relevant.map((fact) => `- [${fact.id}] ${fact.fact} (${fact.area} · ${fact.confidence})`).join(`
34873
+ `);
34874
+ }
34875
+ function watcherRelPath(featureId2) {
34876
+ return `${WATCH_PHASE}/${WATCHERS_DIR}/${featureId2}.md`;
34877
+ }
34878
+ function featureBrief(feature) {
34879
+ const path = watcherRelPath(feature.id);
34880
+ const stories = feature.stories.map((s) => s.story.id);
34881
+ return [
34882
+ `Write exactly ONE file: \`${path}\`. Do not write, edit or delete anything else.`,
34883
+ "",
34884
+ "Its YAML front matter is fixed — copy it verbatim:",
34885
+ "",
34886
+ "```yaml",
34887
+ "---",
34888
+ "version: 1",
34889
+ `id: ${feature.id}`,
34890
+ `epic: ${feature.epicId}`,
34891
+ `title: ${JSON.stringify(feature.title)}`,
34892
+ `stories: [${stories.join(", ")}]`,
34893
+ `repos: [${feature.repos.join(", ")}]`,
34894
+ "status: draft",
34895
+ "---",
34896
+ "```",
34897
+ "",
34898
+ `Then these H2 sections, in this order: ${WATCHER_SECTIONS.map((s) => `\`## ${s}\``).join(" · ")}.`,
34899
+ "",
34900
+ "- Every list item under **Signal**, **Where**, **Healthy baseline** and **Looks broken when**",
34901
+ " ends with a `[src: …]` token: `<repo>:<path>:<line>` for a line in the built code, `F<n>` for",
34902
+ " a recorded fact, or `absent:<what you looked at>` when the code emits nothing.",
34903
+ `- **Where** names a PLACE, and a place is cited like a line: ${NON_FILE_SOURCE_CURE}.`,
34904
+ " An item with no token at all is refused, however obvious the place. One example, for a table",
34905
+ " that no fact and no dashboard names yet:",
34906
+ "",
34907
+ " - PostgreSQL `leaderboard_refreshes` table, read with `psql` [src: api:db/migrations/0007_leaderboard_refreshes.sql:1]",
34908
+ "",
34909
+ "- **Signal** names the log line, metric or event that is IN the diff above, at the line it is on.",
34910
+ " If nothing is emitted, say so with an `absent:` source and say what to instrument. Do not",
34911
+ " describe a signal that would be nice to have as though it exists.",
34912
+ "- **Query** is one fenced block, copy-pasteable in whatever place **Where** names.",
34913
+ " If — and ONLY if — the code emits nothing at all, so there is no place to paste a query into,",
34914
+ " write ONE line instead of the block, sourced like every other claim on the card:",
34915
+ "",
34916
+ " Query: none — <why nothing is queryable> [src: absent:<what you looked at>]",
34917
+ "",
34918
+ " This is CHECKED, not trusted: it is refused unless **Signal** above cites `absent:` and the",
34919
+ " reason's own source is `absent:` too. A card that names a real signal has somewhere to point a",
34920
+ " query at, so `none` on one is a shortcut and will fail the stage. An unsourced `none` is refused",
34921
+ " exactly like an unsourced bullet, and describing a query in prose is still refused either way.",
34922
+ "- **Sources** is prose: each citation above, once, with what it establishes.",
34923
+ "",
34924
+ "Owner (optional, gh #70). A Signal item may name WHO to ask about it, as `(owner: <name>)`",
34925
+ "placed BEFORE its `[src: …]` token — the token is still the last thing on the line:",
34926
+ "",
34927
+ " - `checkout.completed` is written on every order (owner: alice) [src: api:src/Checkout.cs:88]",
34928
+ "",
34929
+ "Write it ONLY from an `ownership` fact inlined above, and write the SAME name that fact uses.",
34930
+ "Put it in the front matter as `owner: <name>` when one name covers every item on the card, or on",
34931
+ "the individual items when they differ. Do not invent a name, do not put a repo name there (the",
34932
+ "framework already derives that from your citation), and leave it off entirely when no fact says.",
34933
+ "An `(owner: )` with nothing in it is refused — it loses the name it was trying to write.",
34934
+ "",
34935
+ "Leave `status: draft`. The framework sets it: a card is stamped `verified` only when nothing",
34936
+ "under **Signal** cites `absent:`. Writing `verified` yourself changes nothing and will be overwritten."
34937
+ ].join(`
34938
+ `);
34939
+ }
34940
+ function previousCard(options) {
34941
+ const rel = watcherRelPath(options.feature.id);
34942
+ const abs = join80(options.runDir, rel);
34943
+ if (!existsSync66(abs))
34944
+ return "";
34945
+ const text3 = readFileSync60(abs, "utf8");
34946
+ if (text3.trim() === "")
34947
+ return "";
34948
+ const card = parseWatcherCard(text3, options.ctx, options.feature.id);
34949
+ const lines = text3.split(`
34950
+ `);
34951
+ const out = [];
34952
+ if (card.ok) {
34953
+ out.push(`The previous attempt at this stage wrote \`${rel}\` and it validates. The stage is being run`, "again for another reason; keep this card as it is unless the evidence above contradicts it.");
34954
+ } else {
34955
+ out.push(`The previous attempt at this stage wrote \`${rel}\` and it was REFUSED — ` + `${String(card.issues.length)} line(s) do not validate. Each one, with the line as written:`, "");
34956
+ for (const issue of card.issues) {
34957
+ out.push(`- ${describeWatcherIssue(issue)}`);
34958
+ const quoted2 = issue.line > 0 ? lines[issue.line - 1] : undefined;
34959
+ if (quoted2 !== undefined && quoted2.trim() !== "")
34960
+ out.push(` > ${quoted2}`);
34961
+ }
34962
+ out.push("", "Fix what is described above. Everything else in this prompt still applies.");
34963
+ }
34964
+ out.push("", `### ${PREVIOUS_ATTEMPT_EDIT_HEADING}`, "", "This card is on disk RIGHT NOW, exactly as the last attempt left it. It is the draft you are", "being paid to fix, not history: keep every item that already carries a `[src: …]` token, cure", "the lines marked above, and write the file back at the same path. Starting from a blank page", "throws away paid-for work — and, measured, moves a refusal to a different line instead of", "curing it.", "");
34965
+ const budget = options.maxBytes ?? MAX_PREVIOUS_ATTEMPT_BYTES;
34966
+ const size = Buffer.byteLength(text3, "utf8");
34967
+ if (size > budget) {
34968
+ out.push(`_Not inlined (past the ${budget.toLocaleString("en-US")}-byte previous-attempt budget): ` + `${rel} (${size.toLocaleString("en-US")} B). It is on disk; read it before you rewrite it._`);
34969
+ } else {
34970
+ const fence = fenceFor(text3);
34971
+ out.push(`#### \`${rel}\``, "", fence, text3.replace(/\n$/, ""), fence);
34972
+ }
34973
+ return out.join(`
34974
+ `);
34975
+ }
34976
+
34829
34977
  // src/core/watch/renderWatchHandoff.ts
34830
34978
  var NO_STORIES_SRC = `absent:${PLAN_PHASE2}/stories`;
34831
34979
  function renderWatchHandoff(cards, ctx) {
@@ -35050,6 +35198,7 @@ async function watchExecutor(ctx) {
35050
35198
  refused: true
35051
35199
  };
35052
35200
  }
35201
+ const srcCtx = toSrcContext(loadWorkspace(ctx.root), ctx.runDir, { epicRefs: true });
35053
35202
  let prompts = [];
35054
35203
  if (ctx.mode !== "commit") {
35055
35204
  const build = recordedBuild(ctx.runDir);
@@ -35068,7 +35217,7 @@ async function watchExecutor(ctx) {
35068
35217
  signature: incoherent.signature
35069
35218
  };
35070
35219
  }
35071
- prompts = features.map((feature, i) => featurePrompt(ctx, feature, diffs[i] ?? []));
35220
+ prompts = features.map((feature, i) => featurePrompt(ctx, feature, diffs[i] ?? [], srcCtx));
35072
35221
  }
35073
35222
  if (ctx.mode === "prepare")
35074
35223
  return prepare2(ctx, features, prompts);
@@ -35109,7 +35258,6 @@ async function watchExecutor(ctx) {
35109
35258
  }
35110
35259
  }
35111
35260
  }
35112
- const srcCtx = toSrcContext(loadWorkspace(ctx.root), ctx.runDir, { epicRefs: true });
35113
35261
  const written = [];
35114
35262
  for (const feature of features) {
35115
35263
  const rel = watcherRelPath(feature.id);
@@ -35196,7 +35344,7 @@ function collectResults(ctx, features) {
35196
35344
  }
35197
35345
  return { tasks, error: null };
35198
35346
  }
35199
- function featurePrompt(ctx, feature, diffs) {
35347
+ function featurePrompt(ctx, feature, diffs, srcCtx) {
35200
35348
  const facts = FactsStore.loadOrEmpty(factsPath(ctx.root)).facts;
35201
35349
  const stageMd = applyCheckContracts(readFileSync62(stageMdPath(ctx.spec.planned.id, ctx.spec.planned.source), "utf8"), {
35202
35350
  checks: ctx.spec.planned.checks.map((check) => check.id),
@@ -35227,7 +35375,8 @@ function featurePrompt(ctx, feature, diffs) {
35227
35375
  budget_usd: agentShare(ctx, 1).toFixed(2)
35228
35376
  },
35229
35377
  experts: bundles.experts,
35230
- inputs: featureInputs({ root: ctx.root, runDir: ctx.runDir, feature, diffs, facts })
35378
+ inputs: featureInputs({ root: ctx.root, runDir: ctx.runDir, feature, diffs, facts }),
35379
+ previousAttempt: previousCard({ runDir: ctx.runDir, feature, ctx: srcCtx })
35231
35380
  });
35232
35381
  return replaceSection(body, "Feature", featureBrief(feature));
35233
35382
  }
@@ -38436,6 +38585,27 @@ function dependencyHoldOfLog(log) {
38436
38585
  const body = (end === -1 ? rest : rest.slice(0, end)).map((line) => line.trim()).filter((line) => line !== "");
38437
38586
  return body.length === 1 && body[0] !== undefined ? dependencyNamedByHold(body[0]) : null;
38438
38587
  }
38588
+ function dependencyHoldCure(held2) {
38589
+ if (dependencyIsPending(held2.status)) {
38590
+ return `${held2.id} \`done\` is what releases it — it is offered again by the next \`tldrx next --prepare\``;
38591
+ }
38592
+ return held2.status === "blocked" ? `${held2.id} will not land in this run as it stands — \`tldrx story reopen ${held2.id} --note "…"\` gives it its turn` : `${held2.id} is \`todo\` after its wave ran, so its developer never delivered — \`tldrx next --prepare\` offers ${held2.id} first`;
38593
+ }
38594
+ function dependencyPrepareRefusal(storyId, held2) {
38595
+ if (dependencyIsPending(held2.status)) {
38596
+ return `nothing to prepare yet: ${storyId} waits on dependency ${held2.id}, which is \`${held2.status}\`, ` + `not \`done\` — ${storyId} is left \`todo\`, not \`blocked\`; ${dependencyHoldCure(held2)}`;
38597
+ }
38598
+ return `nothing to review: ${dependencyHoldReason(held2)}, so ${storyId} has no developer turn to review and ` + `none is owed — \`tldrx next --prepare\` records ${storyId} \`blocked\` with that reason; ${dependencyHoldCure(held2)}`;
38599
+ }
38600
+ function dependencyCommitRefusal(storyId, held2, work) {
38601
+ return `${storyId} is not settled: ${dependencyHoldReason(held2)}, and a story does not land over a dependency ` + `that has not. Nothing is lost — the bundle stays at ${work.bundleDir}, the work stays on \`${work.branch}\` ` + `in ${work.worktree}, and ${storyId} stays \`in_progress\`; ${dependencyHoldCure(held2)}, ` + `then \`tldrx next --commit\` settles ${storyId}`;
38602
+ }
38603
+ function dependencyNextLine(storyId, held2) {
38604
+ if (dependencyIsPending(held2.status)) {
38605
+ return `nothing is next yet — ${storyId} waits on dependency ${held2.id}, which is \`${held2.status}\`, ` + `not \`done\`; ${dependencyHoldCure(held2)}`;
38606
+ }
38607
+ return `nothing is next — ${dependencyHoldReason(held2)}, so \`tldrx next --prepare\` records ${storyId} ` + `\`blocked\` with that reason and closes the stage at its gate; ${dependencyHoldCure(held2)}`;
38608
+ }
38439
38609
 
38440
38610
  // src/core/facilitator/reviewerModel.ts
38441
38611
  function resolveReviewer(layers) {
@@ -39302,12 +39472,18 @@ class BuildSession {
39302
39472
  return await this.finish();
39303
39473
  }
39304
39474
  async prepare() {
39305
- const planned = this.nextPending();
39475
+ let planned = this.nextPending();
39306
39476
  if (planned === null)
39307
39477
  return await this.finish();
39308
39478
  const refusal = await this.refuseOnDirtyRepos() ?? await this.refuseOnForeignEpic() ?? await this.setAsideForeign() ?? await this.refuseOnRedBase() ?? await this.refuseOnUnrunnableWorktree();
39309
39479
  if (refusal !== null)
39310
39480
  return refusal;
39481
+ const offered = this.offerAtFrontier();
39482
+ if (offered.kind === "wait")
39483
+ return refusedOnSequence(this.ctx, dependencyPrepareRefusal(offered.story, offered.held));
39484
+ if (offered.kind === "none")
39485
+ return await this.finish();
39486
+ planned = offered.planned;
39311
39487
  const review = this.reviewWorkFor(planned);
39312
39488
  if (review !== null)
39313
39489
  return await this.prepareReview(planned, review);
@@ -39399,6 +39575,9 @@ class BuildSession {
39399
39575
  const refusal = await this.refuseOnDirtyRepos() ?? await this.refuseOnForeignEpic() ?? await this.setAsideForeign();
39400
39576
  if (refusal !== null)
39401
39577
  return refusal;
39578
+ const held2 = this.blockingDependency(planned);
39579
+ if (held2 !== null)
39580
+ return refusedOnSequence(this.ctx, dependencyPrepareRefusal(planned.story.id, held2));
39402
39581
  const work = this.reviewWorkFor(planned) ?? this.reviewWorkFromLedger(planned);
39403
39582
  if (work === null) {
39404
39583
  return refusedOnSequence(this.ctx, `${planned.story.id} has no merged commit to review — a story is reviewed after its developer turn, ` + "not instead of one. Run `tldrx next --prepare` for the developer half first.");
@@ -39493,7 +39672,7 @@ class BuildSession {
39493
39672
  lines: [
39494
39673
  ...this.lines,
39495
39674
  `${planned.story.id} → \`${outcome?.status ?? "?"}\` (host review, unmetered)`,
39496
- `${this.nextPending()?.story.id ?? "?"} is next — run \`tldrx next --prepare\``
39675
+ this.nextAtFrontier()
39497
39676
  ],
39498
39677
  stderr: [...this.advisories],
39499
39678
  error: null
@@ -39505,6 +39684,14 @@ class BuildSession {
39505
39684
  if (planned === null) {
39506
39685
  return refusedOnSequence(this.ctx, "no story is `in_progress` — run `tldrx next --prepare` first");
39507
39686
  }
39687
+ const held2 = this.blockingDependency(planned);
39688
+ if (held2 !== null) {
39689
+ return refusedOnSequence(this.ctx, dependencyCommitRefusal(planned.story.id, held2, {
39690
+ bundleDir: relative16(this.ctx.root, agentDir(this.ctx.runDir, this.bundleKey(planned.story.id))),
39691
+ branch: storyBranchOf(this.ctx.runId, planned.story.id),
39692
+ worktree: relative16(this.ctx.root, this.storyWorktree(planned))
39693
+ }));
39694
+ }
39508
39695
  const key = this.bundleKey(planned.story.id);
39509
39696
  let result2;
39510
39697
  try {
@@ -39553,11 +39740,14 @@ class BuildSession {
39553
39740
  lines: [
39554
39741
  ...this.lines,
39555
39742
  `${planned.story.id} → \`${outcome?.status ?? "?"}\``,
39556
- `${this.nextPending()?.story.id ?? "?"} is next — run \`tldrx next --prepare\``
39743
+ this.nextAtFrontier()
39557
39744
  ],
39558
39745
  error: null
39559
39746
  };
39560
39747
  }
39748
+ cancelledUnder() {
39749
+ return RunStore.cancelledOnDisk(this.ctx.runDir);
39750
+ }
39561
39751
  async driveStory(planned) {
39562
39752
  const resume = this.resumableReview(planned);
39563
39753
  if (resume !== null) {
@@ -39565,6 +39755,10 @@ class BuildSession {
39565
39755
  return;
39566
39756
  }
39567
39757
  for (let i = 0;i < this.attempts; i++) {
39758
+ if (this.cancelledUnder()) {
39759
+ this.lines.push(` · ${planned.story.id}: ${i === 0 ? "not started" : "not requeued"} — the run was cancelled ` + "(tldrx run cancel) while this stage held it");
39760
+ return;
39761
+ }
39568
39762
  await this.settleHalf(await this.buildHalf(planned));
39569
39763
  const outcome = this.outcomes.get(planned.story.id);
39570
39764
  if (outcome !== undefined && outcome.developerError !== null)
@@ -39614,6 +39808,10 @@ class BuildSession {
39614
39808
  const planned = queue[cursor++];
39615
39809
  if (planned === undefined)
39616
39810
  return;
39811
+ if (this.cancelledUnder()) {
39812
+ this.lines.push(` · ${planned.story.id}: not started — the run was cancelled (tldrx run cancel) while this stage held it`);
39813
+ return;
39814
+ }
39617
39815
  halves.set(planned.story.id, await this.buildHalf(planned));
39618
39816
  }
39619
39817
  };
@@ -39940,6 +40138,29 @@ class BuildSession {
39940
40138
  });
39941
40139
  return "settled";
39942
40140
  }
40141
+ if (supplied === undefined && this.cancelledUnder()) {
40142
+ const review2 = reviewerUnfunded("the run was cancelled (tldrx run cancel) before the review — the diff is merged on the epic " + "branch and nobody has judged it");
40143
+ this.ctx.emit("check.failed", {
40144
+ phase: this.ctx.phaseId,
40145
+ check: "review",
40146
+ story: story.planned.story.id,
40147
+ verdict: review2.verdict,
40148
+ attempt: story.attempt,
40149
+ detail: review2.summary
40150
+ });
40151
+ await this.settle(story, "review", {
40152
+ dod,
40153
+ commit: commit2,
40154
+ merged: true,
40155
+ carried,
40156
+ epicBase,
40157
+ verdict: "n-a",
40158
+ review: review2,
40159
+ cost: round23(priorCost),
40160
+ reason: review2.summary
40161
+ });
40162
+ return "settled";
40163
+ }
39943
40164
  const outcome = supplied === undefined ? await this.spawnReviewer(story, dod, epicBase) : { review: supplied, cost: 0 };
39944
40165
  const review = outcome.review;
39945
40166
  const cost = round23(priorCost + outcome.cost);
@@ -40187,6 +40408,41 @@ class BuildSession {
40187
40408
  }
40188
40409
  return decidingHold(holds);
40189
40410
  }
40411
+ offerAtFrontier() {
40412
+ const walked = this.walkFrontier(true);
40413
+ if (walked.kind === "held" && !walked.recorded)
40414
+ return { kind: "wait", story: walked.story, held: walked.held };
40415
+ if (walked.kind === "held")
40416
+ return { kind: "none" };
40417
+ return walked;
40418
+ }
40419
+ nextAtFrontier() {
40420
+ const walked = this.walkFrontier(false);
40421
+ if (walked.kind === "offer")
40422
+ return `${walked.planned.story.id} is next — run \`tldrx next --prepare\``;
40423
+ if (walked.kind === "held")
40424
+ return dependencyNextLine(walked.story, walked.held);
40425
+ return "nothing is next — run `tldrx next --prepare` to close the stage at its gate";
40426
+ }
40427
+ walkFrontier(record3) {
40428
+ let recorded = false;
40429
+ let first2 = null;
40430
+ for (const planned of this.pendingStories()) {
40431
+ const held2 = this.blockingDependency(planned);
40432
+ if (held2 === null)
40433
+ return { kind: "offer", planned };
40434
+ first2 ??= { story: planned.story.id, held: held2 };
40435
+ if (dependencyIsPending(held2.status)) {
40436
+ if (record3)
40437
+ this.waitOnDependency(planned, held2);
40438
+ continue;
40439
+ }
40440
+ if (record3)
40441
+ this.blockOnDependency(planned, held2);
40442
+ recorded = true;
40443
+ }
40444
+ return first2 === null ? { kind: "none" } : { kind: "held", ...first2, recorded };
40445
+ }
40190
40446
  waitOnDependency(planned, held2) {
40191
40447
  this.waits.set(planned.story.id, held2);
40192
40448
  this.lines.push(dependencyWaitLine(planned.story.id, held2));
@@ -42312,6 +42568,13 @@ async function runExecutor(store, options, phaseId, stageId, spec, notes, execut
42312
42568
  return failStage(store, options, phaseId, stageId, `recording this invocation's task rows threw — ${String(recorded - tasksBefore)} of ` + `${String(outcome.tasks.length)} rows are in run.yml and the rest are not: ${why}`, notes, recorded);
42313
42569
  }
42314
42570
  store.save();
42571
+ if (store.cancelledUnder) {
42572
+ return out(EXIT_OK4, [
42573
+ ...notes,
42574
+ ...outcome.lines,
42575
+ `run ${store.runId} was cancelled while ${phaseId}/${stageId} ran — stopped here: ` + `${String(outcome.tasks.length)} task row(s) from this invocation are recorded, and nothing more is spawned`
42576
+ ], outcome.stderr ?? []);
42577
+ }
42315
42578
  if (outcome.refused === true) {
42316
42579
  setStatus(store, phaseId, stageId, "ready");
42317
42580
  store.save();
@@ -42790,7 +43053,6 @@ function assemblePrompt(store, options, spec, stage2, inputs, ctx, seed) {
42790
43053
  dispatchNotes
42791
43054
  };
42792
43055
  }
42793
- var MAX_PREVIOUS_ATTEMPT_BYTES = 32 * 1024;
42794
43056
  function describePreviousAttempt(stage2, options) {
42795
43057
  const lines = [];
42796
43058
  const failure = [...stage2.tasks].reverse().find((task2) => task2.error !== null)?.error ?? null;
@@ -42833,7 +43095,7 @@ function priorOutputs(options) {
42833
43095
  return [];
42834
43096
  const out = [
42835
43097
  "",
42836
- "### Previous attempt — edit, do not restart",
43098
+ `### ${PREVIOUS_ATTEMPT_EDIT_HEADING}`,
42837
43099
  "",
42838
43100
  "These files are on disk RIGHT NOW, exactly as the last attempt left them. They are",
42839
43101
  "not a suggestion and they are not history: they are the draft you are being paid to",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tldr-experts",
3
- "version": "0.24.0",
3
+ "version": "0.25.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.24.0",
5
+ "version": "0.25.0",
6
6
  "author": {
7
7
  "name": "Alan Martinez"
8
8
  }
@@ -56,7 +56,8 @@ these H2 sections, in order:
56
56
  ## Rules
57
57
 
58
58
  - Every list item under Signal / Where / Healthy baseline / Looks broken when is ONE
59
- line and ENDS with a `[src: …]` token. An item without one is refused.
59
+ line and ENDS with a `[src: …]` token. An item without one is refused — a table, queue,
60
+ dashboard or console included: cite the file that defines it (**Feature** above shows one).
60
61
  - Source grammar (exact): `[src: <one or more sources separated by "; ">]`, a source being
61
62
  `<repo>:<path>:<line>` (or `:<start>-<end>`) · `F<n>` · `https://…` ·
62
63
  `$ <command> → exit <n>` · `absent:<path>`. Never a whole file, never a comma list.
@@ -38,9 +38,14 @@ status: draft # draft | verified — verified only when Signal ha
38
38
 
39
39
  ## Where
40
40
 
41
- > The dashboard, log stream, table or query console this is read in.
41
+ > The dashboard, log stream, table or query console this is read in. A place is
42
+ > cited like a line: a table, queue, dashboard or console is not a line of code,
43
+ > so cite the file that DEFINES it — the migration, model or config — or the
44
+ > `F<n>` fact that names it. An item with no token is refused, however obvious
45
+ > the place.
42
46
 
43
47
  - Application Insights → `traces`, filtered to the message above [src: F014]
48
+ - PostgreSQL `leaderboard_refreshes` table, read with `psql` — no dashboard reads it yet [src: api:db/migrations/0007_leaderboard_refreshes.sql:1]
44
49
 
45
50
  ## Healthy baseline
46
51