trantor 0.18.63 → 0.18.64

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.63",
3
+ "version": "0.18.64",
4
4
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
5
5
  "mcpServers": {
6
6
  "relay": {
package/bin/cli.mjs CHANGED
@@ -132,6 +132,7 @@ switch (cmd) {
132
132
  case "orchestrate": run("bin/orchestrate.mjs"); break;
133
133
  case "app": run("bin/app.mjs"); break;
134
134
  case "patrol": run("bin/patrol.mjs"); break;
135
+ case "retire": run("bin/retire.mjs"); break;
135
136
  case "identity": {
136
137
  const { load, publicView, generate, keyPath } = await import(join(ROOT, "lib/identity.mjs"));
137
138
  const sub = args[0], name = args[1] || "human";
@@ -271,6 +272,7 @@ switch (cmd) {
271
272
  trantor duty the always-on fleet duty agent: up | down | status — hub-escalated triage so you are not the switchboard
272
273
  runs on sonnet by default (it never writes code); duty up --model <m> to pick, or --model inherit for the CLI default
273
274
  trantor orchestrate a per-project ORCHESTRATOR with a MISSION.md and a pulse: up [--every 10m] | down | status — the loop-orchestrator pattern
275
+ trantor retire retire orchestrator panes nothing is using — preview by default [--hours N] [--yes] [--json]
274
276
  trantor patrol machine-wide resource sweep: crews/runners/workspaces/orphans — [--json] [--reap] (reap = dead rows + stale artifacts ONLY)
275
277
 
276
278
  Claude Code plugin (the orchestrator side):
package/bin/retire.mjs ADDED
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ // `trantor retire` — retire orchestrator panes that nothing is using (#8017). Previews by default;
3
+ // --yes performs it. Liveness, never age alone, decides: a pane mid-turn or holding an in-flight
4
+ // contract is held open whatever its idle age.
5
+ import { execFileSync } from "node:child_process";
6
+ import { turnInFlight, buildSummary, writeHandoff, sessionProcessState } from "../hooks/lib/handoff.mjs";
7
+ import { hostId, resolveHub } from "../lib/project.mjs";
8
+ import { sfetchJson } from "../lib/signed-fetch.mjs";
9
+ import {
10
+ retireHours, retireEnabled, collectPanes, retireDecision, retirePane,
11
+ isRetired, humanHours, retiredLedgerPath,
12
+ } from "../lib/retire-panes.mjs";
13
+
14
+ const D = "\x1b[2m", B = "\x1b[1m", Y = "\x1b[33m", G = "\x1b[32m", R = "\x1b[0m";
15
+ const args = process.argv.slice(2);
16
+ const flag = n => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : null; };
17
+ const APPLY = args.includes("--yes");
18
+ const JSON_OUT = args.includes("--json");
19
+
20
+ const hours = flag("--hours") !== null ? Number(flag("--hours")) : retireHours();
21
+ if (!retireEnabled(hours)) {
22
+ console.log(`retirement disabled (threshold ${hours}h) — set TRANTOR_PANE_RETIRE_HOURS or config.paneRetireHours`);
23
+ process.exit(0);
24
+ }
25
+
26
+ const host = hostId();
27
+ // Fail CLOSED on the hub: a contract count we could not read must never be taken as zero, or an
28
+ // unreachable hub would retire a pane that is holding work.
29
+ async function contractsFor(session) {
30
+ try {
31
+ const res = await sfetchJson(`${resolveHub("")}/contracts?session=${encodeURIComponent(session)}`,
32
+ { method: "GET", name: session });
33
+ if (!res.ok) return 1;
34
+ return Number((await res.json())?.open) || 0;
35
+ } catch { return 1; }
36
+ }
37
+
38
+ const panes = collectPanes({ turnInFlight, sessionProcessState, hostId: host }).filter(p => !isRetired(p.sid));
39
+ for (const p of panes) p.openContracts = await contractsFor(`${host}:${p.project}`);
40
+ const decided = panes.map(p => retireDecision(p, { hours }));
41
+ const due = decided.filter(d => d.retire);
42
+
43
+ if (JSON_OUT) {
44
+ console.log(JSON.stringify({ hours, applied: APPLY, panes: decided }, null, 2));
45
+ }
46
+
47
+ if (!JSON_OUT) {
48
+ console.log(`${B}retire${R} ${D}· threshold ${hours}h · ledger ${retiredLedgerPath()}${R}`);
49
+ for (const d of decided) {
50
+ const mark = d.retire ? `${Y}retire${R}` : `${G}hold ${R}`;
51
+ const age = d.idleMs === null ? "?" : humanHours(d.idleMs);
52
+ console.log(` ${mark} ${d.project.padEnd(18)} ${D}idle ${age.padEnd(7)} ${d.reason}${R}`);
53
+ }
54
+ if (!decided.length) console.log(` ${D}no orchestrator panes in the session map${R}`);
55
+ }
56
+
57
+ if (!due.length) process.exit(0);
58
+ if (!APPLY) {
59
+ if (!JSON_OUT) console.log(`\n${D}preview only — rerun with --yes to retire ${due.length} pane(s)${R}`);
60
+ process.exit(0);
61
+ }
62
+
63
+ for (const d of due) {
64
+ const out = await retirePane(d, { by: `retire@${host}`, exec: execFileSync, writeHandoff, buildSummary });
65
+ console.log(` ${G}retired${R} ${d.project} ${D}${out.steps.join(" · ")}${R}`);
66
+ console.log(` ${D}resume the thread with: claude --resume ${d.sid}${R}`);
67
+ }
@@ -616,6 +616,13 @@ export function turnsBeforeCut(rows) {
616
616
  return cut >= 0 ? cut : (rows || []).length;
617
617
  }
618
618
 
619
+ /** Was this run STOPPED, or did it reach its own end? §8.7 is a runway metric and runway only means
620
+ * something for a run something cut short (#8066). Kept separate from turnsBeforeCut so that
621
+ * function's return shape — and every caller reading it as a number — stays as it was. */
622
+ export function wasCut(rows) {
623
+ return (rows || []).some(r => r?.cut === true);
624
+ }
625
+
619
626
  /**
620
627
  * §8.7 — median turns-per-card on the state path vs the baseline path, over n ≥ MIN_CARDS.
621
628
  *
@@ -625,16 +632,35 @@ export function turnsBeforeCut(rows) {
625
632
  */
626
633
  export function turnsGate(pairs, { minCards = MIN_CARDS } = {}) {
627
634
  const usable = (pairs || []).filter(p => p.state?.length && p.baseline?.length)
628
- .map(p => ({ card: p.card, state: turnsBeforeCut(p.state), baseline: turnsBeforeCut(p.baseline) }));
635
+ .map(p => ({
636
+ card: p.card,
637
+ state: turnsBeforeCut(p.state), baseline: turnsBeforeCut(p.baseline),
638
+ state_cut: wasCut(p.state), baseline_cut: wasCut(p.baseline),
639
+ }));
629
640
  const out = { cards: usable, n: usable.length, state_median: median(usable.map(u => u.state)), baseline_median: median(usable.map(u => u.baseline)), ok: false, code: null, message: "" };
630
641
  if (usable.length < minCards) {
631
642
  out.code = "CARRY_FORWARD";
632
643
  out.message = `n=${usable.length} card(s) with both paths recorded; §8.7 wants ≥${minCards}. The medians are recorded on the card and this gate CARRIES FORWARD to the next phase — it is not waived.`;
633
644
  return out;
634
645
  }
646
+ // Runway is only a question for a run something STOPPED. A state run that reached its own end has
647
+ // no runway problem, and scoring it against a baseline that WAS stopped inverts the metric — the
648
+ // better the state path does, the worse this reads. That is not hypothetical: the first real
649
+ // Phase-2a run finished card #6448 in ONE turn against a 149-turn baseline that never finished,
650
+ // and this gate called it FEWER_TURNS (#8066). The regression §8.7 exists to catch is the state
651
+ // path being cut EARLIER than the prose path, and that still fails below.
652
+ const stopped = usable.filter(u => u.state_cut);
653
+ out.cut_n = stopped.length;
654
+ if (!stopped.length) {
655
+ out.ok = true;
656
+ out.message = `no state run was forced to cut across n=${usable.length} card(s) — every card reached its own end, so there is no runway to compare (state median ${out.state_median} turns vs baseline ${out.baseline_median})`;
657
+ return out;
658
+ }
659
+ out.state_median = median(stopped.map(u => u.state));
660
+ out.baseline_median = median(stopped.map(u => u.baseline));
635
661
  out.ok = out.state_median >= out.baseline_median;
636
662
  out.code = out.ok ? null : "FEWER_TURNS";
637
- out.message = `median turns before a forced cut — state ${out.state_median} vs baseline ${out.baseline_median} over n=${usable.length}`;
663
+ out.message = `median turns before a forced cut, over the ${stopped.length} card(s) whose state run WAS cut — state ${out.state_median} vs baseline ${out.baseline_median} (n=${usable.length} recorded)`;
638
664
  return out;
639
665
  }
640
666
 
@@ -645,7 +671,14 @@ export function evaluateRun({ project, card, repo = REPO, ...opts }) {
645
671
  const gates = [];
646
672
  const base = requireBaseline({ project, card, repo });
647
673
  gates.push({ n: 1, name: "baseline committed (§7.5)", ok: base.ok, code: base.code ?? null, message: base.ok ? `${base.doc} in HEAD · ${base.rows.length} baseline turns` : base.message });
648
- if (!base.ok) return { ok: false, gates, halted: "gate 1", project, card };
674
+ // §7.5 exists so the >=5x CLAIM cannot be made against a number recorded after the fact, and that
675
+ // still holds: with no baseline the phase does NOT open and this run cannot pass. But halting here
676
+ // also blocked gates 3, 5 and 6 — which read the state run alone and never touch the baseline — so
677
+ // "does the evidence pipeline work at all" was unanswerable for any card without prose-path
678
+ // history. Since every card that HAS a baseline is now done, that was every card worth asking
679
+ // about. The unfalsifiability guard is kept where it belongs (gates 4 and 7, the comparisons) and
680
+ // the mechanism gates are allowed to report.
681
+ const noBaseline = !base.ok;
649
682
 
650
683
  const steps = readJsonl(runPath(project, card));
651
684
  if (!steps || !steps.length) {
@@ -658,8 +691,15 @@ export function evaluateRun({ project, card, repo = REPO, ...opts }) {
658
691
  const cache = checkCache(steps);
659
692
  gates.push({ n: 3, name: "cache read > 0 after the first step (§8.3)", ok: cache.ok, code: cache.code, message: cache.message });
660
693
 
661
- const cost = costGate(steps, base.rows, { weights: opts.weights || null });
662
- gates.push({ n: 4, name: `cost: flat curve, ≥${COST_FACTOR}× below baseline (§8.4)`, ok: cost.ok, code: cost.code, message: cost.message, numbers: cost });
694
+ // The ONE gate §7.5's guard actually protects: no recorded-first baseline, no ratio, no claim.
695
+ const cost = noBaseline ? null : costGate(steps, base.rows, { weights: opts.weights || null });
696
+ gates.push({ n: 4, name: `cost: flat curve, ≥${COST_FACTOR}× below baseline (§8.4)`,
697
+ ok: noBaseline ? false : cost.ok,
698
+ code: noBaseline ? "NO_BASELINE" : cost.code,
699
+ message: noBaseline
700
+ ? "no committed baseline for this card — the ≥5× claim is measured against a number recorded BEFORE the thing that judges it (§7.5), and there is none, so no ratio is computed and none is guessed"
701
+ : cost.message,
702
+ numbers: cost });
663
703
 
664
704
  const dist = disturbanceCheck(steps);
665
705
  gates.push({ n: 5, name: "zero recovery after a mid-run disturbance (§8.5)", ok: dist.ok, code: dist.code, message: dist.message, cases: dist.cases });
@@ -673,7 +713,7 @@ export function evaluateRun({ project, card, repo = REPO, ...opts }) {
673
713
 
674
714
  // §8.4's "at equal-or-better task success": the cache claim can hold while the card gets less far.
675
715
  const turns = turnsGate(collectTurnPairs(project));
676
- gates.push({ n: 7, name: `turns per card, median over n≥${MIN_CARDS} (§8.7)`, ok: turns.ok, code: turns.code, message: turns.message, numbers: turns });
716
+ gates.push({ n: 7, name: `turns per card, median over n≥${MIN_CARDS} (§8.7)`, ok: noBaseline ? false : turns.ok, code: noBaseline ? "NO_BASELINE" : turns.code, message: noBaseline ? "no committed baseline for this card — §8.7 pairs the state path against the prose path and has nothing to pair with" : turns.message, numbers: turns });
677
717
 
678
718
  return { ok: gates.every(g => g.ok), gates, project, card, baseline: base, steps: steps.length };
679
719
  }
@@ -553,14 +553,16 @@ def cmd_call(reg, args):
553
553
  try:
554
554
  resp = http_post(url, headers, payload)
555
555
  except urllib.error.HTTPError as e:
556
- body = e.read().decode(errors="replace")[:500]
556
+ # ONE stderr line naming the status and the model (#6893): a provider body with
557
+ # newlines must not smear the ✗ across the caller's log — collapse, then clamp.
558
+ body = " ".join(e.read().decode(errors="replace").split())[:500]
557
559
  err(RED("🪙 scrooge ✗ %s/%s HTTP %s: %s" % (provider, model, e.code, body)))
558
560
  append_ledger({"ts": int(t0), "provider": provider, "model": model, "task": args.task,
559
561
  "project": proj, "cwd": cwd,
560
562
  "ok": False, "error": "HTTP %s" % e.code, "duration_ms": int((time.time()-t0)*1000)})
561
563
  raise SystemExit(2)
562
564
  except Exception as e:
563
- err(RED("🪙 scrooge ✗ %s/%s: %s" % (provider, model, e)))
565
+ err(RED("🪙 scrooge ✗ %s/%s: %s" % (provider, model, " ".join(str(e).split()))))
564
566
  append_ledger({"ts": int(t0), "provider": provider, "model": model, "task": args.task,
565
567
  "project": proj, "cwd": cwd,
566
568
  "ok": False, "error": str(e), "duration_ms": int((time.time()-t0)*1000)})
@@ -586,6 +588,12 @@ def cmd_call(reg, args):
586
588
  "prompt_preview": preview})
587
589
  err(ORANGE("🪙 scrooge ✓ %s/%s · %d→%d tok · ~$%.5f · %.1fs%s" %
588
590
  (provider, model, tin, tout, c, dt, (" · ledger#%d" % line_no) if line_no else "")))
591
+ # A genuinely empty model answer is a SUCCESS the caller must be able to tell from a
592
+ # failed call (#6893): exit 0, stdout stays EMPTY (not even a newline), and stderr
593
+ # carries the "empty answer" note — the ✗/exit-2 path is the only other possibility.
594
+ if not text.strip():
595
+ err(DIM("🪙 scrooge · empty answer"))
596
+ return
589
597
  sys.stdout.write(text)
590
598
  if not text.endswith("\n"):
591
599
  sys.stdout.write("\n")
@@ -14,11 +14,18 @@ try {
14
14
  const { file, record } = result;
15
15
  process.stderr.write(`[trantor] baton handoff written: ${file}\n`);
16
16
  await pingBus(basename(projectDir), record.id, conf);
17
- if (maybeSpawn(projectDir, conf)) { // open the fresh session that takes over
17
+ // The handoff file goes in: a pane baton cannot be driven without it (#8089).
18
+ if (maybeSpawn(projectDir, conf, file)) { // open the fresh session that takes over
18
19
  // AUTO baton: close the original ONLY when config.autoCloseOriginal is true; an auto-close must
19
- // never kill an in-flight session, so the default leaves the original alive.
20
+ // never kill an in-flight session, so the default leaves the original alive. A pane baton
21
+ // replaces its own pane and resolves no window, so there is nothing here to arm.
20
22
  const armed = windowId ? armBatonClose(file, windowId, tty, conf, { auto: true }) : false;
21
23
  process.stderr.write(`[trantor] fresh session spawned${armed ? ` · baton-close armed for window ${windowId}` : " · original window left alive (auto-close off by default)"}\n`);
24
+ } else {
25
+ // #8089's second half: this used to be an `if` with no `else`, so a declined spawn printed
26
+ // NOTHING. The record was written, no successor came, and the only evidence was a handoff stuck
27
+ // at `written`. A path that decides not to act still has to say so.
28
+ process.stderr.write(`[trantor] NO successor session opened for ${basename(projectDir)} — handoff ${basename(file)} is written but unclaimed; open one to take over\n`);
22
29
  }
23
30
  } catch (e) {
24
31
  process.stderr.write(`[trantor] handoff-now error: ${e?.message || e}\n`);
@@ -738,26 +738,58 @@ export async function pingBus(projectName, id, conf = readConfig()) {
738
738
  // Spawn a fresh same-agent session (macOS) that takes over via the handoff.
739
739
  // Default = ON (prompt with a timeout, default button "Open fresh session").
740
740
  // Disable with config.autoHandoffPrompt:false or env TRANTOR_NO_HANDOFF_SPAWN=1.
741
- export function maybeSpawn(projectDir, conf = readConfig()) {
741
+ export function maybeSpawn(projectDir, conf = readConfig(), handoffFile = "", deps = {}) {
742
+ // Injection points, for the same reason spawnBaton has them: "A DRILL MUST BE ABLE TO SAY NO —
743
+ // a path that spawns windows needs an off switch or it cannot be tested honestly." maybeSpawn had
744
+ // none, so its pane branch was never drilled, and that is exactly where #8089 lived for weeks.
745
+ const _pane = deps.paneSurfaceEnv || paneSurfaceEnv;
746
+ const _spawnPane = deps.spawnPaneBaton || spawnPaneBaton;
747
+ const _hasPane = deps.hasOrchPane || hasOrchPane;
748
+ const _platform = deps.platform || process.platform;
749
+ const _env = deps.env || process.env;
750
+ const _log = deps.log || ((s) => process.stderr.write(s));
742
751
  try {
743
- if (process.platform !== "darwin") return false;
744
- if (process.env.TRANTOR_NO_HANDOFF_SPAWN === "1") return false;
745
- // #6074: a session in a hosted pane never gets a Terminal window — the pane is the successor
746
- // surface, and the pane claims the handoff (trantor open) on its own.
747
- if (paneSurfaceEnv()) {
748
- process.stderr.write(`[trantor] session lives in herdr pane ${paneSurfaceEnv()} no Terminal window; the pane claims the handoff\n`);
749
- return false;
752
+ if (_platform !== "darwin") return false;
753
+ if (_env.TRANTOR_NO_HANDOFF_SPAWN === "1") return false;
754
+ // #8089: a pane session gets NO Terminal window — but it does get a successor. This used to
755
+ // return false on the theory that "the pane claims the handoff (trantor open) on its own",
756
+ // and for an ARMED baton nothing was driving that: /trantor:handoff always runs inside a turn,
757
+ // so it always arms, so this is always the path taken and spawnPaneBaton, which the direct
758
+ // path (spawnBaton) calls right here, was never reached. The record was written and the session
759
+ // sat there. Witnessed on crebral-health 2026-09-19: written 00:18:58, unclaimed, original alive.
760
+ const paneId = _pane(_env);
761
+ if (paneId) {
762
+ if (!handoffFile) {
763
+ _log(`[trantor] pane ${paneId} needs the handoff file to pass the baton and none was given — no successor opened\n`);
764
+ return false;
765
+ }
766
+ const ok = _spawnPane(projectDir, handoffFile, paneId);
767
+ _log(`[trantor] herdr pane ${paneId}: ${ok ? "baton driver spawned — it replaces this pane in place" : "baton driver FAILED to spawn — no successor"}\n`);
768
+ return ok;
750
769
  }
751
770
  if (conf.autoHandoffPrompt === false) return false;
752
- if (hasOrchPane(basename(projectDir))) {
753
- process.stderr.write(`[trantor] orch pane hosts ${basename(projectDir)} no Terminal window; the pane claims the handoff on its next open\n`);
754
- return false;
771
+ if (_hasPane(basename(projectDir))) {
772
+ // Same correction as above for the cwd-keyed pane: drive the replacement, do not assume
773
+ // something else will. Without HERDR_PANE_ID the driver resolves the pane from crew-windows.
774
+ if (!handoffFile) {
775
+ _log(`[trantor] orch pane hosts ${basename(projectDir)} but no handoff file was given — no successor opened\n`);
776
+ return false;
777
+ }
778
+ const ok = _spawnPane(projectDir, handoffFile);
779
+ _log(`[trantor] orch pane hosts ${basename(projectDir)}: ${ok ? "baton driver spawned — it replaces the pane in place" : "baton driver FAILED to spawn — no successor"}\n`);
780
+ return ok;
755
781
  }
756
782
  const script = join(HERE, "..", "..", "bin", "handoff-prompt.sh");
757
- if (!existsSync(script)) { process.stderr.write(`[trantor] handoff-prompt.sh missing\n`); return false; }
783
+ if (!existsSync(script)) { _log(`[trantor] handoff-prompt.sh missing\n`); return false; }
758
784
  const timeout = String(conf.handoffPromptTimeout || 25);
759
- const child = spawn("/bin/bash", [script, projectDir, timeout], { detached: true, stdio: "ignore" });
760
- child.unref();
785
+ // Injectable for the same reason the pane legs are: this line opens a REAL Terminal window, and
786
+ // a drill that reaches it opens one per run. That is not hypothetical — test-pane-baton-spawn's
787
+ // "no pane" case fell through to here and opened a window on every `npm test`, with a comment
788
+ // above it claiming the drill did not exercise this leg. Four of them were sitting on the
789
+ // operator's desktop before anyone noticed, and only a non-existent fixture path stopped each
790
+ // one from starting a live billable session.
791
+ const child = (deps.spawnPrompt || spawn)("/bin/bash", [script, projectDir, timeout], { detached: true, stdio: "ignore" });
792
+ if (child && typeof child.unref === "function") child.unref();
761
793
  return true;
762
794
  } catch (e) { process.stderr.write(`[trantor] maybeSpawn error: ${e?.message}\n`); return false; }
763
795
  }
package/lib/project.mjs CHANGED
@@ -190,6 +190,26 @@ export function writeOrchSession(project, sid, by = "unknown") {
190
190
  return true;
191
191
  } catch { return false; }
192
192
  }
193
+ // Drop a project's row from the map. A session the map does not name cannot be resolved as a wake
194
+ // recipient (bin/wake-nudge.mjs), which is how a retired pane stops buying turns (#8017).
195
+ export function clearOrchSession(project, by = "unknown") {
196
+ try {
197
+ if (!project) return false;
198
+ const p = orchSessionsPath();
199
+ if (!existsSync(p)) return false;
200
+ const rows = readFileSync(p, "utf8").split("\n").filter(Boolean);
201
+ const prev = rows.find(r => r.split("\t")[0] === project)?.split("\t")[1] || "";
202
+ if (!prev) return false;
203
+ const kept = rows.filter(r => r.split("\t")[0] !== project);
204
+ writeFileSync(p, kept.length ? kept.join("\n") + "\n" : "");
205
+ try {
206
+ appendFileSync(join(busDir(), "orch-sessions.log"),
207
+ `${new Date().toISOString()}\t${project}\t${prev}\t-\t${by}\n`);
208
+ } catch {}
209
+ return true;
210
+ } catch { return false; }
211
+ }
212
+
193
213
  function configPath() { return join(busDir(), "config.json"); }
194
214
 
195
215
  export function readConfig() {
@@ -0,0 +1,205 @@
1
+ // Retirement for ORCHESTRATOR panes (#8017). The reaper owns crew seats and tracking rows; an
2
+ // orchestrator pane belongs to nobody, so an untouched one stays a wakeable target carrying
3
+ // week-old context. This decides which panes may retire and performs the retirement with the
4
+ // machinery that already exists — the handoff writer, the session map, herdr.
5
+ import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync, statSync, readdirSync } from "node:fs";
6
+ import { join, dirname } from "node:path";
7
+ import { homedir } from "node:os";
8
+ import { execFileSync } from "node:child_process";
9
+ import { busDir, readConfig, orchSessionsPath, clearOrchSession, checkoutFor } from "./project.mjs";
10
+
11
+ export const DEFAULT_RETIRE_HOURS = 24;
12
+ const HOUR_MS = 60 * 60 * 1000;
13
+
14
+ /** Where a deliberate retirement is recorded. Its absence is what makes a gone pane a CRASH. */
15
+ export function retiredLedgerPath(bus = busDir()) { return join(bus, "retired-panes.jsonl"); }
16
+
17
+ // Idle age alone never retires anything, so the threshold is only ever half the decision. 0 or a
18
+ // negative value disables retirement outright — the operator's off switch.
19
+ export function retireHours({ env = process.env, config = readConfig() } = {}) {
20
+ const raw = env.TRANTOR_PANE_RETIRE_HOURS ?? config?.paneRetireHours;
21
+ if (raw === undefined || raw === null || raw === "") return DEFAULT_RETIRE_HOURS;
22
+ const n = Number(raw);
23
+ return Number.isFinite(n) ? n : DEFAULT_RETIRE_HOURS;
24
+ }
25
+
26
+ export function retireEnabled(hours) { return Number.isFinite(hours) && hours > 0; }
27
+
28
+ /** Every project→session row in the orchestrator map. One row per project, TAB separated. */
29
+ export function orchSessionRows(path = orchSessionsPath()) {
30
+ try {
31
+ return readFileSync(path, "utf8").split("\n").flatMap(line => {
32
+ const [project, sid] = line.split("\t");
33
+ return project && sid && sid.trim() ? [{ project: project.trim(), sid: sid.trim() }] : [];
34
+ });
35
+ } catch { return []; }
36
+ }
37
+
38
+ /** The transcript for a session id, wherever its project slug lives (a renamed dir keeps the old). */
39
+ export function transcriptFor(sid, { claudeProjectsDir = join(homedir(), ".claude", "projects") } = {}) {
40
+ if (!sid) return "";
41
+ try {
42
+ for (const d of readdirSync(claudeProjectsDir)) {
43
+ const t = join(claudeProjectsDir, d, `${sid}.jsonl`);
44
+ if (existsSync(t)) return t;
45
+ }
46
+ } catch {}
47
+ return "";
48
+ }
49
+
50
+ /** How long since this pane's thread was last written to. No transcript = no evidence = null. */
51
+ export function idleMsFor(transcript, now = Date.now()) {
52
+ try { return Math.max(0, now - statSync(transcript).mtimeMs); } catch { return null; }
53
+ }
54
+
55
+ // LIVENESS DECIDES, age only qualifies. Every one of these holds a pane open at ANY age: a pane
56
+ // mid-deploy read exactly like its five idle siblings on the morning this card was written.
57
+ export function livenessHold({ turnInFlight = false, agentStatus = "", openContracts = 0, transcriptMissing = false, processState = "unknown" } = {}) {
58
+ if (transcriptMissing) return "no transcript on disk — nothing to hand off, and nothing proves it idle";
59
+ // A turn only counts as in flight while something is still running it. A transcript frozen
60
+ // mid-turn whose process is provably gone is a corpse, and a corpse is not work (#6668).
61
+ if (turnInFlight && processState !== "dead") return "mid-turn: the transcript's last row still owes a result";
62
+ if (["working", "busy"].includes(String(agentStatus))) return `herdr reports the agent ${agentStatus}`;
63
+ if (openContracts > 0) return `${openContracts} contract(s) in flight`;
64
+ return "";
65
+ }
66
+
67
+ /** The whole decision for one pane: retire, or hold with a reason a person can read. */
68
+ export function retireDecision(pane, { hours, now = Date.now() } = {}) {
69
+ const idleMs = pane.idleMs;
70
+ const hold = livenessHold(pane);
71
+ if (hold) return { ...pane, retire: false, reason: hold };
72
+ if (idleMs === null) return { ...pane, retire: false, reason: "idle age unknown" };
73
+ const thresholdMs = hours * HOUR_MS;
74
+ if (idleMs < thresholdMs) return { ...pane, retire: false, reason: `idle ${humanHours(idleMs)} — under the ${hours}h threshold` };
75
+ return { ...pane, retire: true, reason: `idle ${humanHours(idleMs)}, no turn in flight, no open contract` };
76
+ }
77
+
78
+ export function humanHours(ms) {
79
+ const h = ms / HOUR_MS;
80
+ return h < 1 ? `${Math.round(ms / 60000)}m` : `${h.toFixed(h < 10 ? 1 : 0)}h`;
81
+ }
82
+
83
+ /** Has this project's pane already been retired? The ledger is the retired/crashed discriminator. */
84
+ export function retiredRows(path = retiredLedgerPath()) {
85
+ try {
86
+ return readFileSync(path, "utf8").split("\n").flatMap(l => {
87
+ if (!l.trim()) return [];
88
+ try { return [JSON.parse(l)]; } catch { return []; }
89
+ });
90
+ } catch { return []; }
91
+ }
92
+
93
+ export function isRetired(sid, path = retiredLedgerPath()) {
94
+ return !!sid && retiredRows(path).some(r => r?.sid === sid);
95
+ }
96
+
97
+ export function recordRetirement(entry, path = retiredLedgerPath()) {
98
+ try {
99
+ mkdirSync(dirname(path), { recursive: true });
100
+ appendFileSync(path, JSON.stringify(entry) + "\n");
101
+ return true;
102
+ } catch { return false; }
103
+ }
104
+
105
+ // ---- the live inputs -------------------------------------------------------
106
+ function herdrJson(args, exec = execFileSync) {
107
+ try { return JSON.parse(exec("herdr", args, { encoding: "utf8", timeout: 15000 })); } catch { return null; }
108
+ }
109
+
110
+ /** herdr's view of this session: its pane id and what the agent is doing right now. */
111
+ export function herdrAgentFor(sid, { exec = execFileSync } = {}) {
112
+ const agents = herdrJson(["agent", "list"], exec)?.result?.agents;
113
+ if (!Array.isArray(agents)) return { pane: "", status: "", proven: false };
114
+ const found = agents.find(a => a?.agent_session?.value === sid);
115
+ return { pane: found?.pane_id || "", status: found?.agent_status || "", proven: true };
116
+ }
117
+
118
+ export function crewWindowsPath(bus = busDir()) { return join(bus, "crew-windows.txt"); }
119
+
120
+ /** The orchestrator pane herdr hosts for a project — last `orch` row wins, as the baton resolves it. */
121
+ export function orchPaneRow(project, path = crewWindowsPath()) {
122
+ let pane = "";
123
+ try {
124
+ for (const line of readFileSync(path, "utf8").split("\n")) {
125
+ const f = line.split("\t");
126
+ if (f[0] === project && f[1] === "orch" && f[3] && f[3].trim()) pane = f[3].trim();
127
+ }
128
+ } catch {}
129
+ return pane;
130
+ }
131
+
132
+ /** Forget the tracked orch row for a retired pane, so no later `up`/`open` treats it as live. */
133
+ export function dropOrchRow(project, path = crewWindowsPath()) {
134
+ try {
135
+ if (!existsSync(path)) return false;
136
+ const rows = readFileSync(path, "utf8").split("\n").filter(Boolean);
137
+ const kept = rows.filter(r => { const f = r.split("\t"); return !(f[0] === project && f[1] === "orch"); });
138
+ if (kept.length === rows.length) return false;
139
+ writeFileSync(path, kept.length ? kept.join("\n") + "\n" : "");
140
+ return true;
141
+ } catch { return false; }
142
+ }
143
+
144
+ /** Gather every orchestrator pane with the facts the decision needs. */
145
+ export function collectPanes({
146
+ rows = orchSessionRows(), now = Date.now(), turnInFlight, sessionProcessState = () => "unknown",
147
+ herdrAgent = herdrAgentFor, contracts = () => 0, hostId = "",
148
+ } = {}) {
149
+ return rows.map(({ project, sid }) => {
150
+ const transcript = transcriptFor(sid);
151
+ const agent = herdrAgent(sid);
152
+ return {
153
+ project, sid, transcript, pane: agent.pane || orchPaneRow(project),
154
+ agentStatus: agent.status,
155
+ idleMs: idleMsFor(transcript, now),
156
+ transcriptMissing: !transcript,
157
+ turnInFlight: transcript ? !!turnInFlight(transcript) : false,
158
+ processState: sessionProcessState(sid),
159
+ openContracts: contracts(hostId ? `${hostId}:${project}` : project),
160
+ };
161
+ });
162
+ }
163
+
164
+ // ---- the act ---------------------------------------------------------------
165
+ // Order matters and is not negotiable: the handoff and the checkpoint are written FIRST, so a
166
+ // failure anywhere after them still leaves the thread recoverable by `claude --resume <sid>`.
167
+ export async function retirePane(pane, {
168
+ now = Date.now(), by = "retire", dry = false, exec = execFileSync,
169
+ writeHandoff, buildSummary, clearMap = clearOrchSession, ledger = retiredLedgerPath(),
170
+ } = {}) {
171
+ const steps = [];
172
+ const projectDir = checkoutFor(pane.project) || "";
173
+ if (dry) {
174
+ return { ...pane, dry: true, steps: ["handoff", "checkpoint", "unmap", "close-pane"], projectDir };
175
+ }
176
+ let handoffFile = "";
177
+ const summary = buildSummary(pane.transcript);
178
+ const written = writeHandoff({
179
+ projectDir: projectDir || pane.project, projectName: pane.project, sessionId: pane.sid,
180
+ transcript: pane.transcript, trigger: "idle-retire", summary, force: true,
181
+ });
182
+ handoffFile = written?.file || "";
183
+ steps.push(handoffFile ? "handoff" : `handoff-skipped:${written?.reason || "unknown"}`);
184
+
185
+ // The checkpoint IS this row: it names the session id, so the conversation is resumable long
186
+ // after the pane is gone, and its presence is what tells a later boot the pane was retired.
187
+ const entry = {
188
+ ts: now, project: pane.project, sid: pane.sid, pane: pane.pane || "",
189
+ idleMs: pane.idleMs, reason: pane.reason || "", handoff: handoffFile,
190
+ resume: `claude --resume ${pane.sid}`, by, retired: true,
191
+ };
192
+ steps.push(recordRetirement(entry, ledger) ? "checkpoint" : "checkpoint-failed");
193
+
194
+ steps.push(clearMap(pane.project, by) ? "unmap" : "unmap-noop");
195
+
196
+ // Closing the pane is what stops herdr restoring it at the next boot: a pane that is no longer
197
+ // in the layout has nothing to resurrect, while a crashed one is still there.
198
+ if (pane.pane) {
199
+ const closed = herdrJson(["pane", "close", pane.pane], exec);
200
+ steps.push(closed && !closed.error ? "close-pane" : "close-pane-failed");
201
+ if (dropOrchRow(pane.project)) steps.push("drop-orch-row");
202
+ } else steps.push("close-pane-skipped:no-pane-id");
203
+
204
+ return { ...pane, steps, handoff: handoffFile, projectDir, entry };
205
+ }
@@ -170,6 +170,12 @@ export function deriveState({ project, seat, card, worktree, handoffText, cardTi
170
170
  now: Number.isInteger(now) ? now : Math.floor(Date.now() / 1000),
171
171
  by: author,
172
172
  files: deriveFiles(worktree),
173
+ // This build is RECONSTRUCTION, not a seat asserting completion (#8068): a ✅ bullet in the
174
+ // predecessor's handoff records what it reported done. The evidence rule guards a live claim,
175
+ // and applying it here would drop the handoff's items on the floor. Credit is withheld the way
176
+ // the contract already says — deriveFiles marks every path verified:false and `verify` stays
177
+ // empty — so the successor must still re-earn evidence before anything NEW moves to done.
178
+ reconstructing: true,
173
179
  };
174
180
 
175
181
  const parsed = parseHandoffState(handoffText);
@@ -9,7 +9,7 @@ import { applyTurn } from "./apply.mjs";
9
9
  import { assemble } from "./assemble.mjs";
10
10
  import { runGate } from "./gate.mjs";
11
11
  import { promote } from "./promote.mjs";
12
- import { commit, gitTouched, hashPaths, readState, STALE } from "./store.mjs";
12
+ import { commit, gitCommittedSince, gitHead, gitTouched, hashPaths, readState, STALE } from "./store.mjs";
13
13
  import { fromEnvelope } from "./cost.mjs";
14
14
  // The paths and budgets belong to §7.3/§7.5 and P7 published them from bin/state-bench.mjs; importing
15
15
  // upward is the deliberate inversion, since no number gets a second home. Its dispatch is guarded.
@@ -150,11 +150,16 @@ export function renderCardTail(rows, card) {
150
150
  /** TIER 1 (§4.8, the live half of R12): `touched` from git, and every credited path RE-HASHED with the
151
151
  * credit dropped where the blob moved or the file is gone. Never sets `verified: true`.
152
152
  * @returns {Record<string, {touched?: boolean, verified?: boolean}>} ctx.files for applyTurn */
153
- export function tier1Files(state, cwd, deps = {}) {
153
+ export function tier1Files(state, cwd, deps = {}, headAtStart = null) {
154
154
  const touch = deps.gitTouched || gitTouched;
155
155
  const hash = deps.hashPaths || hashPaths;
156
+ const since = deps.gitCommittedSince || gitCommittedSince;
156
157
  const files = {};
157
- for (const p of touch(cwd)) {
158
+ // Three readers, not the two §4.8 named. status and diff-HEAD both measure the working tree
159
+ // AGAINST HEAD, so a seat that commits carries HEAD along with its work and both read empty —
160
+ // which is why a turn that rewrote 8,000 lines across 4 commits recorded files:{} (#8069).
161
+ // The range from this step's starting HEAD is the half that sees committed work.
162
+ for (const p of [...touch(cwd), ...since(headAtStart, cwd)]) {
158
163
  if (typeof p !== "string" || !p || p.length > CAPS.PATH) continue;
159
164
  files[p] = { touched: true };
160
165
  }
@@ -232,7 +237,7 @@ export async function runStep({
232
237
  }) {
233
238
  const D = {
234
239
  readState, commit, applyTurn, runGate, promote, assemble,
235
- gitTouched, hashPaths, appendJsonl,
240
+ gitTouched, gitCommittedSince, gitHead, hashPaths, appendJsonl,
236
241
  callCli: async () => ({ exit: 1, stdout: "" }),
237
242
  executeAction: () => undefined,
238
243
  ...deps,
@@ -275,6 +280,11 @@ export async function runStep({
275
280
  // §7.3's fallback ladder: a MALFORMED patch earns exactly one retry, with the rejection message
276
281
  // as the observation. An EVIDENCE rejection is not retried in-turn — the code was wrong, not the
277
282
  // grammar, and the seat needs a whole step to fix the code (§4.8).
283
+ // Where HEAD sat before this step ran anything (#8069). Captured ONCE, outside the retry loop:
284
+ // a malformed-output retry re-runs the CLI, and commits the first attempt already made are still
285
+ // this step's work — re-reading HEAD per attempt would silently drop them.
286
+ const headAtStart = (D.gitHead || gitHead)(cwd);
287
+
278
288
  for (;;) {
279
289
  // ---- 2. assemble ----
280
290
  trace.push("assemble");
@@ -292,7 +302,7 @@ export async function runStep({
292
302
 
293
303
  // ---- 4. TIER 1: git touch + credit expiry, EVERY turn, before the apply ----
294
304
  trace.push("tier1");
295
- const files = tier1Files(state, cwd, D);
305
+ const files = tier1Files(state, cwd, D, headAtStart);
296
306
  ctx = { ...baseCtx, files };
297
307
 
298
308
  if (!parsed.turn) {
@@ -83,6 +83,26 @@ function git(args, cwd) {
83
83
  }
84
84
  }
85
85
 
86
+ /** The commit HEAD points at, or null when there is no repo or no commit yet (#8069). Tier 1 records
87
+ * this BEFORE the CLI runs so it can later ask what the turn committed. */
88
+ export function gitHead(cwd) {
89
+ const out = git(["rev-parse", "HEAD"], cwd);
90
+ const sha = out === null ? "" : out.trim();
91
+ return /^[0-9a-f]{40}$/.test(sha) ? sha : null;
92
+ }
93
+
94
+ /** Paths changed by the COMMITS made since `sha`, NUL-separated (#8069). The two commands §4.8 named
95
+ * — `git status --porcelain` and `git diff --name-only HEAD` — both measure the working tree against
96
+ * HEAD, so a seat that commits moves HEAD along with its work and both come back empty. That is not a
97
+ * corner case: every seat is told to commit early and often so a cut cannot erase it, which made tier 1
98
+ * blind on the ordinary flow. A card that rewrote 8,000 lines across 4 commits recorded files:{}.
99
+ * Degrades to [] on any git error, exactly like the other readers — recovery never throws. */
100
+ export function gitCommittedSince(sha, cwd) {
101
+ if (!sha || !cwd) return [];
102
+ const out = git(["diff", "--name-only", "-z", `${sha}..HEAD`], cwd);
103
+ return out === null ? [] : out.split("\0").filter(Boolean);
104
+ }
105
+
86
106
  /** Every path git says changed, from `git status -z -uall`: -z because `--short` C-quotes unusual paths
87
107
  * and a mis-parse credits a file that does not exist; -uall so untracked dirs yield files. A rename yields both paths. */
88
108
  export function gitTouched(cwd) {
@@ -49,6 +49,40 @@ export function hasEvidence(state, item, ctx = {}) {
49
49
  return paths.every(p => ({ ...(state.files[p] || {}), ...(ctxFiles[p] || {}) }).verified === true);
50
50
  }
51
51
 
52
+ /** The evidence gate for an item ARRIVING in `done`, by whichever op put it there (#8068). Returns the
53
+ * rejection, or null when the item may land. `move → done` had this inline and `add … list:"done"` had
54
+ * nothing at all, so a seat that completed an item in the turn it did the work — the ordinary flow —
55
+ * walked past the verified-done rule entirely. Both doors now lead through this one check; `at` is the
56
+ * only thing that differs, so the seat's rejection still names the op it actually wrote. */
57
+ function arrivalInDone(state, item, ctx, at) {
58
+ if (!item || hasEvidence(state, item, ctx)) return null;
59
+ // The one exception, and it is reconstruction rather than assertion: derive.mjs rebuilds a
60
+ // WorkingState from a predecessor's prose handoff, where a ✅ bullet is a RECORD of what was
61
+ // reported done, not this seat claiming it now. Gating that would delete the handoff's content —
62
+ // #6528's failure exactly — and the rule it would be enforcing is already kept another way:
63
+ // CONTRACT-state.md, "a derived state carries no credit … the successor must re-earn its evidence
64
+ // before anything moves to done". The items arrive uncredited, so the successor still cannot ride
65
+ // route (b) on them. `ctx` is harness-supplied and a patch cannot write it (§3.0 write matrix),
66
+ // so a seat cannot set this for itself — which is the only reason a flag is safe here at all.
67
+ if (ctx.reconstructing === true) return null;
68
+ // The split that makes §4.8's one-retry bound a fact: a gate that ran and failed looks
69
+ // exactly like a gate that never ran, unless the driver says which happened.
70
+ if (ctx.gate_attempted) {
71
+ const g = ctx.gate_attempted;
72
+ return {
73
+ ...reject(ERR.UNVERIFIED_DONE, at,
74
+ `${at} → done rejected: the gate ran and did not pass (${g.cmd || "gate"}, exit ${g.exit}). ` +
75
+ `Fix the failure, then land it.`),
76
+ gate: { cmd: g.cmd, exit: g.exit, tail: g.tail },
77
+ };
78
+ }
79
+ return {
80
+ ...reject(ERR.NEEDS_GATE, at,
81
+ `${at} → done needs evidence: no gate has run at this state. Run it, then re-apply.`),
82
+ gate: { items: [item.id], paths: item.paths || [] },
83
+ };
84
+ }
85
+
52
86
  /** Stages 1-3: normalised ops on success, the rejection the seat reads on failure. `ctx.gate_attempted`
53
87
  * splits NEEDS_GATE from UNVERIFIED_DONE; without it §4.8's one-retry bound is false. */
54
88
  export function validateTurn(state, turn, ctx = {}) {
@@ -154,6 +188,10 @@ export function validateTurn(state, turn, ctx = {}) {
154
188
  `${list} is at CAPS.LIST (${CAPS.LIST}) and is a working list: overflow is a rejection, not a silent drop`);
155
189
  }
156
190
  }
191
+ if (list === "done") {
192
+ const bad = arrivalInDone(state, ev.item, ctx, `add:${item.id}`);
193
+ if (bad) return bad;
194
+ }
157
195
  listOf.set(item.id, list);
158
196
  itemOf.set(item.id, ev.item);
159
197
  lengths[list]++;
@@ -192,25 +230,8 @@ export function validateTurn(state, turn, ctx = {}) {
192
230
  return reject(ERR.CAP, `move:${id}`, `${to} is at CAPS.LIST (${CAPS.LIST})`);
193
231
  }
194
232
  if (to === "done") {
195
- const item = itemOf.get(id);
196
- if (item && !hasEvidence(state, item, ctx)) {
197
- // The split that makes §4.8's one-retry bound a fact: a gate that ran and failed looks
198
- // exactly like a gate that never ran, unless the driver says which happened.
199
- if (ctx.gate_attempted) {
200
- const g = ctx.gate_attempted;
201
- return {
202
- ...reject(ERR.UNVERIFIED_DONE, `move:${id}`,
203
- `move ${id} → done rejected: the gate ran and did not pass (${g.cmd || "gate"}, exit ${g.exit}). ` +
204
- `Fix the failure, then move it.`),
205
- gate: { cmd: g.cmd, exit: g.exit, tail: g.tail },
206
- };
207
- }
208
- return {
209
- ...reject(ERR.NEEDS_GATE, `move:${id}`,
210
- `move ${id} → done needs evidence: no gate has run at this state. Run it, then re-apply.`),
211
- gate: { items: [id], paths: item.paths || [] },
212
- };
213
- }
233
+ const bad = arrivalInDone(state, itemOf.get(id), ctx, `move:${id}`);
234
+ if (bad) return bad;
214
235
  }
215
236
  listOf.set(id, to);
216
237
  lengths[from]--;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.63",
3
+ "version": "0.18.64",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"