trantor 0.18.63 → 0.18.65
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/.claude-plugin/plugin.json +1 -1
- package/bin/baton-pane.mjs +1 -1
- package/bin/cli.mjs +2 -0
- package/bin/retire.mjs +67 -0
- package/bin/state-bench.mjs +46 -6
- package/engine/bin/scrooge +10 -2
- package/hooks/handoff-now.mjs +9 -2
- package/hooks/lib/handoff.mjs +113 -16
- package/hooks/sessionstart.mjs +7 -4
- package/hooks/stop-inbox.mjs +22 -4
- package/lib/project.mjs +20 -0
- package/lib/retire-panes.mjs +205 -0
- package/lib/state/derive.mjs +6 -0
- package/lib/state/driver.mjs +15 -5
- package/lib/state/store.mjs +20 -0
- package/lib/state/validate.mjs +40 -19
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.65",
|
|
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/baton-pane.mjs
CHANGED
|
@@ -33,7 +33,7 @@ const log = (s) => { try { appendFileSync(logFile, `${new Date().toISOString()}
|
|
|
33
33
|
|
|
34
34
|
// Same text as the app's kickoff (lib.rs KICKOFF_PROMPT) — one boot prompt so the successor
|
|
35
35
|
// recaps unprompted instead of sitting idle until a human types (the 15-minute silence, #5649).
|
|
36
|
-
const KICKOFF_PROMPT = "You have just taken over via handoff.
|
|
36
|
+
const KICKOFF_PROMPT = "You have just taken over via handoff. FIRST open every file it names as read-first — memory, PRD, TDD — before you answer or touch anything; the summary points at them, it does not replace them (#8162). THEN recap per your instructions.";
|
|
37
37
|
|
|
38
38
|
// The pane, resolved exactly like the app does (orch_pane_from_rows): last orch row wins.
|
|
39
39
|
export function orchPane(rows, project) {
|
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
|
+
}
|
package/bin/state-bench.mjs
CHANGED
|
@@ -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 => ({
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
662
|
-
|
|
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
|
}
|
package/engine/bin/scrooge
CHANGED
|
@@ -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
|
-
|
|
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")
|
package/hooks/handoff-now.mjs
CHANGED
|
@@ -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
|
-
|
|
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`);
|
package/hooks/lib/handoff.mjs
CHANGED
|
@@ -738,33 +738,130 @@ 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 (
|
|
744
|
-
if (
|
|
745
|
-
// #
|
|
746
|
-
//
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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 (
|
|
753
|
-
|
|
754
|
-
|
|
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)) {
|
|
783
|
+
if (!existsSync(script)) { _log(`[trantor] handoff-prompt.sh missing\n`); return false; }
|
|
758
784
|
const timeout = String(conf.handoffPromptTimeout || 25);
|
|
759
|
-
|
|
760
|
-
|
|
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
|
}
|
|
764
796
|
|
|
797
|
+
/** The files a handoff tells its successor to read before doing anything (#8162).
|
|
798
|
+
*
|
|
799
|
+
* A handoff has always been able to SAY "read these first" — crebral-health's named three memory
|
|
800
|
+
* files on 2026-09-19 and the successor opened none of them. Saying it was the whole mechanism.
|
|
801
|
+
* This pulls the list out as data so something downstream can check it.
|
|
802
|
+
*
|
|
803
|
+
* Recognised: a `READ FIRST` / `READ-FIRST` / `Read first:` heading or line, and every path-looking
|
|
804
|
+
* token on it and the lines beneath it until the next blank line or heading. Deliberately narrow —
|
|
805
|
+
* a handoff that mentions a file in passing is not asking anyone to read it, and a gate that fires
|
|
806
|
+
* on every path in a 4k summary would be noise the successor learns to ignore.
|
|
807
|
+
* @returns {string[]} project-relative or absolute paths, de-duplicated, capped
|
|
808
|
+
*/
|
|
809
|
+
export function readFirstPaths(summary, { max = 12 } = {}) {
|
|
810
|
+
const text = String(summary || "");
|
|
811
|
+
const out = [];
|
|
812
|
+
const lines = text.split(/\r?\n/);
|
|
813
|
+
let collecting = false;
|
|
814
|
+
for (const raw of lines) {
|
|
815
|
+
const line = raw.trim();
|
|
816
|
+
const opens = /read[\s-]?first/i.test(line);
|
|
817
|
+
if (!collecting && !opens) continue;
|
|
818
|
+
if (collecting && (line === "" || /^#{1,6}\s/.test(line))) break;
|
|
819
|
+
collecting = true;
|
|
820
|
+
// Paths: a token with a slash or a known doc/memory extension, stripped of markdown furniture.
|
|
821
|
+
for (const m of line.matchAll(/[`"'(\[]?([~./\w-]*[\w-]+\.(?:md|mjs|js|ts|tsx|json|sql|rs|py|sh|txt))[`"')\]]?/g)) {
|
|
822
|
+
const p = m[1].replace(/^[`"'(\[]+|[`"')\]]+$/g, "");
|
|
823
|
+
if (p && !out.includes(p)) out.push(p);
|
|
824
|
+
if (out.length >= max) return out;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
return out;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** Which of `paths` this session actually OPENED, read off its own transcript (#8162).
|
|
831
|
+
*
|
|
832
|
+
* Ground truth rather than testimony, the same rule the state gate learned the hard way: a
|
|
833
|
+
* successor saying "I have read the handoff" is not evidence that it did. A Read/Grep/Glob tool
|
|
834
|
+
* call naming the path is. Matches on basename as well as full path, because a handoff written by
|
|
835
|
+
* a model may name `revenue-integrity-build.md` where the Read call carries the absolute path.
|
|
836
|
+
* @returns {{read: string[], missed: string[]}}
|
|
837
|
+
*/
|
|
838
|
+
export function pathsReadIn(transcriptPath, paths) {
|
|
839
|
+
const want = (paths || []).filter(Boolean);
|
|
840
|
+
if (!want.length) return { read: [], missed: [] };
|
|
841
|
+
let body = "";
|
|
842
|
+
try { body = readFileSync(transcriptPath, "utf8"); } catch { return { read: [], missed: want }; }
|
|
843
|
+
// Only the tool CALLS count. A path quoted in the injected handoff summary, or in the model's own
|
|
844
|
+
// prose, must never read as "opened" — that is precisely the proxy this card exists to kill.
|
|
845
|
+
const opened = new Set();
|
|
846
|
+
for (const m of body.matchAll(/"name"\s*:\s*"(Read|Grep|Glob|NotebookRead)"\s*,\s*"input"\s*:\s*\{([^}]*)\}/g)) {
|
|
847
|
+
for (const f of m[2].matchAll(/"(?:file_path|path|pattern)"\s*:\s*"([^"]+)"/g)) opened.add(f[1]);
|
|
848
|
+
}
|
|
849
|
+
const hit = (p) => {
|
|
850
|
+
const base = p.split("/").pop();
|
|
851
|
+
for (const o of opened) if (o === p || o.endsWith("/" + p) || (base && o.endsWith("/" + base)) || o === base) return true;
|
|
852
|
+
return false;
|
|
853
|
+
};
|
|
854
|
+
const read = want.filter(hit);
|
|
855
|
+
return { read, missed: want.filter(p => !read.includes(p)) };
|
|
856
|
+
}
|
|
857
|
+
|
|
765
858
|
// The self-announcing fresh session command (single-quoted so it survives osascript→shell un-escaped).
|
|
766
|
-
// Brevity is part of the prompt: a takeover that answers with 5k-character status dumps loses the
|
|
767
|
-
|
|
859
|
+
// Brevity is part of the prompt: a takeover that answers with 5k-character status dumps loses the
|
|
860
|
+
// operator. But brevity is about what you SAY, and #8162 found it had quietly become permission not
|
|
861
|
+
// to READ: the summary is injected at SessionStart, so a 3-sentence recap is producible without
|
|
862
|
+
// opening a file, and a successor doing exactly as asked never opened one. Three days of takeovers
|
|
863
|
+
// went straight to code off a summary. So the order is now read-then-recap, and the recap stays short.
|
|
864
|
+
export const RECAP_CMD = "claude 'You have just taken over via handoff. FIRST open every file the handoff names as read-first — its memory files, its PRD and TDD — and do not answer until you have. They are the context the handoff exists to carry, and the summary is a pointer to them, not a substitute. THEN recap in at most 3 sentences: task, state, next step. Then wait for me. Keep all replies short by default: no status tables, no headers, no walls of text unless I explicitly ask for detail.'";
|
|
768
865
|
|
|
769
866
|
// ONE suppression check for every path that can open a terminal window: two names for it once let a
|
|
770
867
|
// drill set the wrong one and open eight live sessions in deleted temp directories, so both are honoured.
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { fileURLToPath } from "node:url";
|
|
|
10
10
|
import { resolveProject, hostId, resolveHubInfo, knownProjects, nonSeatReason, nestedProjects, handoffDir, readOrchSession, writeOrchSession } from "../lib/project.mjs";
|
|
11
11
|
import { formatSubagentManifest } from "../lib/subagent-manifest.mjs";
|
|
12
12
|
import { updateAvailable, maybeNotifyDesktop, readConfig } from "./lib/update-check.mjs";
|
|
13
|
-
import { renderStateBlock } from "./lib/handoff.mjs";
|
|
13
|
+
import { renderStateBlock, readFirstPaths} from "./lib/handoff.mjs";
|
|
14
14
|
import { maybeCheckBalances } from "./lib/balance-check.mjs";
|
|
15
15
|
import { getJSON, signedGet, signedPost, loadIdentity } from "./lib/api.mjs";
|
|
16
16
|
import { ledgerPaths, ensureStart, anchorCursor, writeCursor } from "./lib/inbox-ledger.mjs";
|
|
@@ -78,8 +78,11 @@ function loadPendingHandoff(projectName, { claim = true, freshSession = null } =
|
|
|
78
78
|
try {
|
|
79
79
|
// #5645: the mandate rides the stamp too, so prompt-focus's recap reminder pins
|
|
80
80
|
// the SAME rec.mode the injection below announces (attended=WAIT / unattended=RESUME).
|
|
81
|
+
// #8162: the READ-FIRST list rides the stamp as DATA, so the Stop hook can check the
|
|
82
|
+
// successor opened it instead of accepting a reply as proof it understood anything.
|
|
83
|
+
const readFirst = readFirstPaths(rec.summary || "");
|
|
81
84
|
writeFileSync(join(dir, `recap-pending-${String(freshSession.session_id).replace(/[^A-Za-z0-9_.-]/g, "_")}.json`),
|
|
82
|
-
JSON.stringify({ handoffId: rec.id, ts: nowSec(), mode: rec.mode === "unattended" ? "unattended" : "attended" }));
|
|
85
|
+
JSON.stringify({ handoffId: rec.id, ts: nowSec(), mode: rec.mode === "unattended" ? "unattended" : "attended", readFirst }));
|
|
83
86
|
} catch {}
|
|
84
87
|
}
|
|
85
88
|
}
|
|
@@ -499,9 +502,9 @@ try {
|
|
|
499
502
|
// attended (default) = recap-then-WAIT; unattended = recap-then-RESUME — the handoff's OPEN
|
|
500
503
|
// THREADS are the work order ("handoffs must never be a break").
|
|
501
504
|
if (handoff.mode === "unattended") {
|
|
502
|
-
additionalContext += `🔄 **You are taking over from a prior session that hit its context limit, in UNATTENDED (long-run) mode.** This is a fresh full window. Resume the work below — the prior session's summary, git state, and a pointer to its full transcript (searchable; Foundation/Gaia has it ingested) follow. Continue from "OPEN THREADS & NEXT STEPS"; do not restart from scratch.
|
|
505
|
+
additionalContext += `🔄 **You are taking over from a prior session that hit its context limit, in UNATTENDED (long-run) mode.** This is a fresh full window. Resume the work below — the prior session's summary, git state, and a pointer to its full transcript (searchable; Foundation/Gaia has it ingested) follow. Continue from "OPEN THREADS & NEXT STEPS"; do not restart from scratch.\n\n**READ BEFORE YOU BUILD (#8162).** This summary is a POINTER to the project's context, not a replacement for it. Open every file this handoff names as read-first — its memory files, its PRD and its TDD — BEFORE you touch code. Reading is not waiting, and it is not optional: three days running, successors recapped from this summary alone and went straight to code, burning a fifth of a context window rediscovering what was already written down. The ledger now checks which files you opened, not whether you replied.\n\nThen recap the task, state, and next step in at most 3 sentences, then RESUME the open threads — they are your work order. Do NOT wait for the user; keep building. Keep replies short: no status tables, no headers, no walls of text.\n\n`;
|
|
503
506
|
} else {
|
|
504
|
-
additionalContext += `🔄 **You are taking over from a prior session that hit its context limit.** This is a fresh full window. Resume the work below — the prior session's summary, git state, and a pointer to its full transcript (searchable; Foundation/Gaia has it ingested) follow. Continue from "OPEN THREADS & NEXT STEPS"; do not restart from scratch.
|
|
507
|
+
additionalContext += `🔄 **You are taking over from a prior session that hit its context limit.** This is a fresh full window. Resume the work below — the prior session's summary, git state, and a pointer to its full transcript (searchable; Foundation/Gaia has it ingested) follow. Continue from "OPEN THREADS & NEXT STEPS"; do not restart from scratch.\n\n**READ BEFORE YOU ANSWER (#8162).** This summary is a POINTER to the project's context, not a replacement for it. Open every file this handoff names as read-first — its memory files, its PRD and its TDD — before you recap. A 3-sentence recap is producible from this summary alone, which is exactly how three days of takeovers went straight to code without opening anything. The ledger now checks which files you opened, not whether you replied.\n\nThen recap the task, state, and next step in at most 3 sentences, then wait. Keep replies short: no status tables, no headers, no walls of text unless the user explicitly asks for detail.\n\n`;
|
|
505
508
|
}
|
|
506
509
|
// Verification gates FIRST — these are structured "must verify before shipping" claims the prior
|
|
507
510
|
// session couldn't independently prove. They go above the summary on purpose: a safety-critical
|
package/hooks/stop-inbox.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { homedir } from "node:os";
|
|
|
11
11
|
import { resolveProject, hostId, handoffDir, busDir } from "../lib/project.mjs";
|
|
12
12
|
import { signedGet } from "./lib/api.mjs"; // signed: enforce hubs 401 unsigned reads — unsigned, T2 delivery is silently dead
|
|
13
13
|
import { ledgerPaths, ensureStart, anchorCursor, writeCursor } from "./lib/inbox-ledger.mjs";
|
|
14
|
-
import { readArm, clearArm, markHandedOff, appendHandoffState, subagentsActive } from "./lib/handoff.mjs";
|
|
14
|
+
import { readArm, clearArm, markHandedOff, appendHandoffState, subagentsActive, pathsReadIn} from "./lib/handoff.mjs";
|
|
15
15
|
|
|
16
16
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
17
17
|
|
|
@@ -162,9 +162,27 @@ async function main() {
|
|
|
162
162
|
if (existsSync(stampPath)) {
|
|
163
163
|
try {
|
|
164
164
|
const stamp = JSON.parse(readFileSync(stampPath, "utf8"));
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
165
|
+
// #8162: RECAPPED used to mean "a reply exists by Stop time", which a successor satisfies
|
|
166
|
+
// from the injected summary alone — so a handoff naming read-first files certified a
|
|
167
|
+
// takeover that opened none of them, three days running. Now the ledger asks the same
|
|
168
|
+
// question the state gate learned to ask: ground truth, not testimony. A Read/Grep tool
|
|
169
|
+
// call naming the path is evidence; the successor's own say-so is not.
|
|
170
|
+
const want = Array.isArray(stamp.readFirst) ? stamp.readFirst : [];
|
|
171
|
+
const { missed } = want.length
|
|
172
|
+
? pathsReadIn(input.transcript_path || "", want)
|
|
173
|
+
: { missed: [] };
|
|
174
|
+
if (missed.length) {
|
|
175
|
+
// The stamp STAYS. Not recapped, so the next boundary asks again — and the successor is
|
|
176
|
+
// told exactly what it skipped rather than left to discover it when the operator does.
|
|
177
|
+
process.stderr.write(
|
|
178
|
+
`[trantor] handoff ${stamp.handoffId}: NOT recapped — the handoff named ${want.length} file(s) to read first and ${missed.length} ${missed.length === 1 ? "was" : "were"} never opened: ${missed.join(", ")}. Read them before going further; this is the context the handoff exists to carry.\n`);
|
|
179
|
+
} else {
|
|
180
|
+
appendHandoffState(stamp.handoffId, "recapped", sid);
|
|
181
|
+
try { unlinkSync(stampPath); } catch {}
|
|
182
|
+
}
|
|
183
|
+
} catch {
|
|
184
|
+
try { unlinkSync(stampPath); } catch {}
|
|
185
|
+
}
|
|
168
186
|
}
|
|
169
187
|
}
|
|
170
188
|
} catch {}
|
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
|
+
}
|
package/lib/state/derive.mjs
CHANGED
|
@@ -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);
|
package/lib/state/driver.mjs
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/lib/state/store.mjs
CHANGED
|
@@ -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) {
|
package/lib/state/validate.mjs
CHANGED
|
@@ -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
|
|
196
|
-
if (
|
|
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]--;
|