trantor 0.18.11 → 0.18.13
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/advise.mjs +8 -3
- package/bin/baton.mjs +7 -0
- package/bin/cli.mjs +2 -0
- package/bin/crew-runner.mjs +16 -1
- package/bin/models.mjs +6 -1
- package/bin/takeover.mjs +134 -0
- package/hooks/lib/handoff.mjs +21 -0
- package/hub.mjs +32 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.13",
|
|
4
4
|
"description": "Trantor \u2014 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/advise.mjs
CHANGED
|
@@ -90,14 +90,19 @@ export function loadWorld() {
|
|
|
90
90
|
|
|
91
91
|
const tierOf = (profile, prov) => profile?.providers?.[prov]?.tier || "api";
|
|
92
92
|
|
|
93
|
+
const FLASH_TIER = /(flash|turbo|lite|mini|highspeed|small)/i;
|
|
94
|
+
|
|
93
95
|
// pick the cheapest Scrooge model that clears the difficulty floor for a task kind
|
|
94
96
|
export function scroogeModelFor(registry, caps, kind = "code", difficulty = "easy") {
|
|
95
97
|
const floor = { easy: 0, medium: 35, hard: 55 }[difficulty] ?? 0;
|
|
96
98
|
const cands = Object.entries(registry.models || {})
|
|
97
99
|
.filter(([, m]) => (m.good_for || []).includes(kind))
|
|
98
|
-
.filter(([id]) => (caps[id]?.coding ?? caps[id]?.intelligence ?? 40) >= floor)
|
|
99
|
-
|
|
100
|
-
|
|
100
|
+
.filter(([id]) => (caps[id]?.coding ?? caps[id]?.intelligence ?? 40) >= floor);
|
|
101
|
+
const hard = difficulty === "hard";
|
|
102
|
+
const strong = hard ? cands.filter(([id]) => !FLASH_TIER.test(id)) : cands;
|
|
103
|
+
const pool = strong.length > 0 ? strong : cands;
|
|
104
|
+
pool.sort((a, b) => (a[1].cost_in + a[1].cost_out) - (b[1].cost_in + b[1].cost_out));
|
|
105
|
+
return pool[0] ? { model: pool[0][0], cost_in: pool[0][1].cost_in, cost_out: pool[0][1].cost_out } : null;
|
|
101
106
|
}
|
|
102
107
|
|
|
103
108
|
// crude per-package token forecast (input+output through the executor)
|
package/bin/baton.mjs
CHANGED
|
@@ -35,6 +35,13 @@ const transcript = findTranscript();
|
|
|
35
35
|
const sessionId = transcript ? basename(transcript, ".jsonl") : "";
|
|
36
36
|
const { file } = writeHandoff({ projectDir: cwd, sessionId, transcript, trigger: "manual-cli", force: true }); // manual = intentional, bypass the storm guard
|
|
37
37
|
console.log(`📋 handoff saved for ${project}: ${file}`);
|
|
38
|
+
// --write-only: the in-app flow (#5509). The app ends the pane's session itself and reopens it
|
|
39
|
+
// through `trantor open`, which claims this handoff — a Terminal window here would be exactly the
|
|
40
|
+
// wrong surface, so the flag writes, announces, and stops.
|
|
41
|
+
if (process.argv.includes("--write-only")) {
|
|
42
|
+
console.log(`🔄 write-only: no window spawned — the pane takeover (trantor open) claims it next.`);
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
38
45
|
const { spawned, armed, windowId } = spawnBaton({ projectDir: cwd, handoffFile: file });
|
|
39
46
|
console.log(spawned
|
|
40
47
|
? `🔄 baton: a fresh session is opening (it'll recap the handoff)${armed ? ` — this window (${windowId}) closes once it takes over` : " — couldn't detect this window; close it yourself once the new one is up"}`
|
package/bin/cli.mjs
CHANGED
|
@@ -73,6 +73,7 @@ switch (cmd) {
|
|
|
73
73
|
case "recost": run("bin/recost.mjs"); break;
|
|
74
74
|
case "handoff": run("bin/baton.mjs"); break;
|
|
75
75
|
case "adopt": run("bin/adopt.mjs"); break;
|
|
76
|
+
case "takeover": run("bin/takeover.mjs"); break;
|
|
76
77
|
case "summarize": run("bin/summarize.mjs"); break;
|
|
77
78
|
case "policy": run("bin/policy.mjs"); break;
|
|
78
79
|
case "proposals": case "proposal": run("bin/proposals.mjs"); break;
|
|
@@ -195,6 +196,7 @@ switch (cmd) {
|
|
|
195
196
|
trantor herdr install|remove|status the login agent that keeps panes alive across a reboot
|
|
196
197
|
trantor autonomy how much Trantor may do unasked: seats, your harness, commit/push/deploy
|
|
197
198
|
trantor adopt take over a session already running in a Terminal, then open it here
|
|
199
|
+
trantor takeover the whole move in one command: idle-gate the Terminal session, end it gracefully, adopt, open in the pane — [--force] [--session <id>] [--dry-run]
|
|
198
200
|
trantor integrate collect the crew's work, merge it, verify it, push it (--dry-run to rehearse)
|
|
199
201
|
trantor down tear the crew down (kills processes, closes windows, no dialogs)
|
|
200
202
|
trantor prune drop dead crew-window tracking rows (ghost workspaces/panes) without spawning anything
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -44,7 +44,22 @@ function ensureSeatWorktree(sourceDir) {
|
|
|
44
44
|
const branch = `seat/${AGENT}`;
|
|
45
45
|
if (existsSync(seatDir)) {
|
|
46
46
|
const ok = gitOut(["-C", seatDir, "rev-parse", "--is-inside-work-tree"], seatDir) === "true";
|
|
47
|
-
if (ok)
|
|
47
|
+
if (ok) {
|
|
48
|
+
// #5403: a worktree created once builds against THAT day's main forever — every wave since
|
|
49
|
+
// has needed a hand fast-forward. Refresh only when it is CLEAN: a dirty tree is a seat's
|
|
50
|
+
// unintegrated work and a diverged branch is a decision, and refreshing must never eat
|
|
51
|
+
// either. Failure to refresh is loud but non-fatal: stale beats broken.
|
|
52
|
+
const dirty = gitOut(["-C", seatDir, "status", "--porcelain"], seatDir);
|
|
53
|
+
if (dirty === "") {
|
|
54
|
+
const head = gitOut(["-C", root, "rev-parse", "HEAD"], root);
|
|
55
|
+
const ff = head && spawnSync("git", ["-C", seatDir, "merge", "--ff-only", head], { stdio: "ignore", timeout: 15000 });
|
|
56
|
+
if (ff && ff.status === 0) console.log(`\x1b[2m[runner]\x1b[0m ${branch} worktree refreshed to ${head.slice(0, 7)}`);
|
|
57
|
+
else console.log(`\x1b[33m[runner]\x1b[0m ${branch} worktree diverged from main HEAD — left as-is (integrate or reset it)`);
|
|
58
|
+
} else {
|
|
59
|
+
console.log(`\x1b[33m[runner]\x1b[0m ${branch} worktree has uncommitted work — not refreshed`);
|
|
60
|
+
}
|
|
61
|
+
return seatDir;
|
|
62
|
+
}
|
|
48
63
|
console.log(`\x1b[33m[runner]\x1b[0m worktree path exists but is not a git worktree: ${seatDir} — using ${sourceDir}`);
|
|
49
64
|
return sourceDir;
|
|
50
65
|
}
|
package/bin/models.mjs
CHANGED
|
@@ -25,9 +25,14 @@ const liveModels = (providerOc) => {
|
|
|
25
25
|
catch { return []; }
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
+
const FLASH_TIER = /(flash|turbo|lite|mini|highspeed|small)/i;
|
|
29
|
+
|
|
28
30
|
function routePick(candList, diff) {
|
|
29
31
|
try {
|
|
30
|
-
const
|
|
32
|
+
const hard = diff === "hard";
|
|
33
|
+
const strong = hard ? candList.filter(id => !FLASH_TIER.test(id)) : candList;
|
|
34
|
+
const pool = strong.length > 0 ? strong : candList;
|
|
35
|
+
const out = execSync(`python3 ${SCROOGE} route --candidates ${JSON.stringify(pool.join(" "))} -t code -d ${diff} --json 2>/dev/null`, { encoding: "utf8" });
|
|
31
36
|
return JSON.parse(out).qualified || "?";
|
|
32
37
|
} catch { return "?"; }
|
|
33
38
|
}
|
package/bin/takeover.mjs
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `trantor takeover` — one command from "the conversation lives in a Terminal window" to "it
|
|
3
|
+
// lives in the pane" (#5495, design: docs/DESIGN-takeover-visibility.md).
|
|
4
|
+
//
|
|
5
|
+
// The chain: inventory → idle gate → graceful end → adopt → open. CLI-first on purpose: the
|
|
6
|
+
// app's button shells THIS command (takeover_now), so the terminal user and the button share one
|
|
7
|
+
// tested implementation — the handoff_now pattern.
|
|
8
|
+
//
|
|
9
|
+
// What this must never do, from the design:
|
|
10
|
+
// - never end a session that wrote its transcript seconds ago without --force (in-flight work);
|
|
11
|
+
// - never pick silently between two live candidates (refuse and show both; --session decides);
|
|
12
|
+
// - never leave the operator with nothing: if open fails after the terminal claude exited, print
|
|
13
|
+
// the exact `claude --resume <sid>` that recovers the thread by hand.
|
|
14
|
+
import { readdirSync, statSync, existsSync } from "node:fs";
|
|
15
|
+
import { join, dirname } from "node:path";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
19
|
+
import { resolveProject } from "../lib/project.mjs";
|
|
20
|
+
|
|
21
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const args = process.argv.slice(2);
|
|
23
|
+
const flag = (n) => args.includes(n);
|
|
24
|
+
const opt = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : null; };
|
|
25
|
+
const JSON_OUT = flag("--json");
|
|
26
|
+
const stages = [];
|
|
27
|
+
const say = (s) => { stages.push(s); if (!JSON_OUT) console.log(s); };
|
|
28
|
+
const out = (ok, extra = {}) => {
|
|
29
|
+
if (JSON_OUT) console.log(JSON.stringify({ ok, stages, ...extra }));
|
|
30
|
+
process.exit(ok ? 0 : 2);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const project = args.find(a => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--session")
|
|
34
|
+
|| resolveProject(process.cwd());
|
|
35
|
+
const devRoot = process.env.TRANTOR_DEV_ROOT || join(homedir(), "development");
|
|
36
|
+
const dir = join(devRoot, project);
|
|
37
|
+
if (!existsSync(dir)) { say(`no local checkout for ${project} (looked in ${devRoot})`); out(false, { reason: "no-checkout" }); }
|
|
38
|
+
|
|
39
|
+
// The idle gate: a transcript written this recently means the session is MID-TURN, and ending it
|
|
40
|
+
// would eat in-flight work. Overridable for drills and deliberate --force.
|
|
41
|
+
export const IDLE_GATE_SEC = Number(process.env.TRANTOR_TAKEOVER_IDLE_SEC || 15);
|
|
42
|
+
|
|
43
|
+
/** The decision table, pure so it can be drilled without processes (test-takeover.mjs). */
|
|
44
|
+
export function decide({ terminalPids, candidates, sessionFlag, force, idleGateSec = IDLE_GATE_SEC }) {
|
|
45
|
+
if (!terminalPids.length) return { action: "open", reason: "no terminal session — plain open (start or reopen the pane)" };
|
|
46
|
+
if (terminalPids.length > 1) {
|
|
47
|
+
return { action: "refuse", reason: `${terminalPids.length} claude sessions run in this directory (pids ${terminalPids.join(", ")}) — close the extras first; a takeover must know which conversation it is adopting` };
|
|
48
|
+
}
|
|
49
|
+
if (!candidates.length) return { action: "refuse", reason: "a claude runs here but no transcript has been written in the last hour — nothing safe to adopt" };
|
|
50
|
+
const chosen = sessionFlag ? candidates.find(c => c.id === sessionFlag) : candidates[0];
|
|
51
|
+
if (sessionFlag && !chosen) return { action: "refuse", reason: `${sessionFlag} is not among the recent transcripts here` };
|
|
52
|
+
if (!sessionFlag && candidates.length > 1) {
|
|
53
|
+
const list = candidates.slice(0, 4).map(c => `${c.id} (${c.ageSec}s ago)`).join(" · ");
|
|
54
|
+
return { action: "refuse", reason: `two live conversations here — pick one with --session <id>: ${list}` };
|
|
55
|
+
}
|
|
56
|
+
if (chosen.ageSec < idleGateSec && !force) {
|
|
57
|
+
return { action: "refuse", reason: `looks MID-TURN (transcript written ${chosen.ageSec}s ago, gate ${idleGateSec}s) — wait for the turn to finish, or --force` };
|
|
58
|
+
}
|
|
59
|
+
return { action: "takeover", sid: chosen.id, pid: terminalPids[0] };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---- inventory (process + filesystem truth only) ----------------------------------------------
|
|
63
|
+
function paneForegroundPgid() {
|
|
64
|
+
try {
|
|
65
|
+
const rows = execFileSync("cat", [join(process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus"), "crew-windows.txt")], { encoding: "utf8" });
|
|
66
|
+
const pane = rows.split("\n").map(l => l.split("\t")).find(f => f[0] === project && f[1] === "orch")?.[3];
|
|
67
|
+
if (!pane) return 0;
|
|
68
|
+
const info = execFileSync("herdr", ["pane", "process-info", "--pane", pane], { encoding: "utf8", timeout: 6000 });
|
|
69
|
+
return Number(JSON.parse(info.slice(info.search(/[[{]/)))?.result?.process_info?.foreground_process_group_id) || 0;
|
|
70
|
+
} catch { return 0; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function terminalClaudePids() {
|
|
74
|
+
let pids = [];
|
|
75
|
+
try { pids = execFileSync("/usr/bin/pgrep", ["-x", "claude"], { encoding: "utf8" }).split("\n").filter(Boolean); } catch { return []; }
|
|
76
|
+
const panePgid = paneForegroundPgid();
|
|
77
|
+
const mine = [];
|
|
78
|
+
for (const pid of pids) {
|
|
79
|
+
if (Number(pid) === panePgid) continue; // the pane's own claude is not a "terminal session"
|
|
80
|
+
try {
|
|
81
|
+
const cwd = execFileSync("/usr/sbin/lsof", ["-a", "-d", "cwd", "-p", pid, "-Fn"], { encoding: "utf8" })
|
|
82
|
+
.split("\n").find(l => l.startsWith("n"))?.slice(1);
|
|
83
|
+
if (cwd === dir) mine.push(Number(pid));
|
|
84
|
+
} catch { /* raced away */ }
|
|
85
|
+
}
|
|
86
|
+
return mine;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function recentCandidates() {
|
|
90
|
+
const slug = dir.replace(/[/.]/g, "-");
|
|
91
|
+
const tdir = join(process.env.TRANTOR_CLAUDE_DIR || join(homedir(), ".claude", "projects"), slug);
|
|
92
|
+
if (!existsSync(tdir)) return [];
|
|
93
|
+
const now = Date.now();
|
|
94
|
+
return readdirSync(tdir).filter(f => f.endsWith(".jsonl"))
|
|
95
|
+
.map(f => { const st = statSync(join(tdir, f)); return { id: f.replace(/\.jsonl$/, ""), ageSec: Math.round((now - st.mtimeMs) / 1000) }; })
|
|
96
|
+
.filter(c => c.ageSec < 3600)
|
|
97
|
+
.sort((a, b) => a.ageSec - b.ageSec);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---- the chain --------------------------------------------------------------------------------
|
|
101
|
+
// Run the chain ONLY when this file is the entrypoint. The first cut used
|
|
102
|
+
// argv[1].endsWith("takeover.mjs"), which is also true for test-takeover.mjs — importing the
|
|
103
|
+
// decision table from the drill file executed a real (luckily idempotent) pane open.
|
|
104
|
+
import { basename as _bn } from "node:path";
|
|
105
|
+
if (process.argv[1] && _bn(process.argv[1]) === "takeover.mjs") {
|
|
106
|
+
const d = decide({ terminalPids: terminalClaudePids(), candidates: recentCandidates(), sessionFlag: opt("--session"), force: flag("--force") });
|
|
107
|
+
if (flag("--dry-run")) { say(`dry-run: ${d.action}${d.reason ? ` — ${d.reason}` : ""}${d.sid ? ` (sid ${d.sid}, pid ${d.pid})` : ""}`); out(true, { decision: d }); }
|
|
108
|
+
if (d.action === "refuse") { say(d.reason); out(false, { reason: d.reason }); }
|
|
109
|
+
|
|
110
|
+
if (d.action === "takeover") {
|
|
111
|
+
say(`ending Terminal session pid ${d.pid} (idle ${IDLE_GATE_SEC}s gate passed)`);
|
|
112
|
+
try { process.kill(d.pid, "SIGTERM"); } catch {}
|
|
113
|
+
const deadline = Date.now() + 8000;
|
|
114
|
+
let alive = true;
|
|
115
|
+
while (alive && Date.now() < deadline) {
|
|
116
|
+
try { process.kill(d.pid, 0); spawnSync("sleep", ["0.3"]); } catch { alive = false; }
|
|
117
|
+
}
|
|
118
|
+
if (alive) { try { process.kill(d.pid, "SIGKILL"); } catch {} say("did not exit in 8s — killed"); }
|
|
119
|
+
else say("session ended cleanly");
|
|
120
|
+
|
|
121
|
+
const adopt = spawnSync(process.execPath, [join(HERE, "adopt.mjs"), project, "--session", d.sid], { encoding: "utf8", timeout: 20000 });
|
|
122
|
+
if (adopt.status !== 0) { say(`adopt failed: ${(adopt.stderr || adopt.stdout || "").trim().slice(0, 200)}`); out(false, { reason: "adopt-failed", sid: d.sid }); }
|
|
123
|
+
say(`adopted ${d.sid} as ${project}'s orchestrator thread`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const open = spawnSync("bash", [join(HERE, "crew.sh"), "open", project], { cwd: dir, encoding: "utf8", timeout: 120000 });
|
|
127
|
+
if (open.status !== 0) {
|
|
128
|
+
say(`open failed: ${(open.stderr || "").trim().slice(0, 200)}`);
|
|
129
|
+
if (d.sid) say(`the conversation is safe on disk — recover by hand: claude --resume ${d.sid}`);
|
|
130
|
+
out(false, { reason: "open-failed", sid: d.sid || null });
|
|
131
|
+
}
|
|
132
|
+
say(`pane hosted: ${(open.stdout || "").trim().split("\n").pop()}`);
|
|
133
|
+
out(true, { sid: d.sid || null });
|
|
134
|
+
}
|
package/hooks/lib/handoff.mjs
CHANGED
|
@@ -423,6 +423,10 @@ export function maybeSpawn(projectDir, conf = readConfig()) {
|
|
|
423
423
|
if (process.platform !== "darwin") return false;
|
|
424
424
|
if (process.env.TRANTOR_NO_HANDOFF_SPAWN === "1") return false;
|
|
425
425
|
if (conf.autoHandoffPrompt === false) return false;
|
|
426
|
+
if (hasOrchPane(basename(projectDir))) {
|
|
427
|
+
process.stderr.write(`[trantor] orch pane hosts ${basename(projectDir)} — no Terminal window; the pane claims the handoff on its next open\n`);
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
426
430
|
const script = join(HERE, "..", "..", "bin", "handoff-prompt.sh");
|
|
427
431
|
if (!existsSync(script)) { process.stderr.write(`[trantor] handoff-prompt.sh missing\n`); return false; }
|
|
428
432
|
const timeout = String(conf.handoffPromptTimeout || 25);
|
|
@@ -446,9 +450,26 @@ export function spawnSuppressed() {
|
|
|
446
450
|
return process.env.TRANTOR_NO_HANDOFF_SPAWN === "1" || process.env.TRANTOR_NO_BATON_SPAWN === "1";
|
|
447
451
|
}
|
|
448
452
|
|
|
453
|
+
// Does this project have a hosted orchestrator pane? When it does, the PANE is the successor
|
|
454
|
+
// surface: `trantor open` claims the handoff there, and spawning a Terminal window would put the
|
|
455
|
+
// fresh session on exactly the surface the operator is trying to leave (#5509 W1). The tracked
|
|
456
|
+
// row is the signal — rows are recorded by open and dropped by teardown/prune, and a stale row
|
|
457
|
+
// costs only a skipped window, never a lost handoff (the handoff waits, held for the pane).
|
|
458
|
+
export function hasOrchPane(projectName) {
|
|
459
|
+
try {
|
|
460
|
+
const state = join(process.env.AGENT_BUS_DIR || process.env.RELAY_DATA_DIR || join(homedir(), ".agent-bus"), "crew-windows.txt");
|
|
461
|
+
if (!existsSync(state)) return false;
|
|
462
|
+
return readFileSync(state, "utf8").split("\n").some(l => {
|
|
463
|
+
const f = l.split("\t");
|
|
464
|
+
return f[0] === projectName && f[1] === "orch";
|
|
465
|
+
});
|
|
466
|
+
} catch { return false; }
|
|
467
|
+
}
|
|
468
|
+
|
|
449
469
|
export function spawnFresh(projectDir) {
|
|
450
470
|
try {
|
|
451
471
|
if (process.platform !== "darwin" || spawnSuppressed()) return false;
|
|
472
|
+
if (hasOrchPane(basename(projectDir))) return false; // the pane is the successor surface (#5509)
|
|
452
473
|
const script = join(HERE, "..", "..", "bin", "open-session.sh");
|
|
453
474
|
if (!existsSync(script)) return false;
|
|
454
475
|
const child = spawn("/bin/bash", [script, projectDir, RECAP_CMD], { detached: true, stdio: "ignore" });
|
package/hub.mjs
CHANGED
|
@@ -752,6 +752,31 @@ function contractsFor(session, { project = "", windowMs = CONTRACT_WINDOW_MS, ov
|
|
|
752
752
|
if (c.ageMs < CONTRACT_ABANDON_MS) continue;
|
|
753
753
|
if (c.ts < (newestAnswered.get(c.to) || 0)) c.disposition = "superseded";
|
|
754
754
|
}
|
|
755
|
+
// ---- superseded by a later DIRECT reply: the morning case (#11047/#11048) --------------------
|
|
756
|
+
// A row can be unanswerable by a seat that is perfectly healthy: its later replies all carry `re`
|
|
757
|
+
// for NEWER contracts (a re-dispatch, or an "ack by reference" threaded to the newer id), so
|
|
758
|
+
// neither byRe nor the loose fallback ever claims the old row — the two matchers above only ever
|
|
759
|
+
// see replies aimed at the NEWER work. The row then sits WAITING forever: it can never be
|
|
760
|
+
// answered, it can never be abandoned (the seat is alive), and the newestAnswered rule above
|
|
761
|
+
// misses it whenever the newer work was dispatched under a peer identity the old row never shares.
|
|
762
|
+
//
|
|
763
|
+
// The signal both matchers ignored is the DIRECT reply itself: if the assignee has sent this
|
|
764
|
+
// session ANY message after the row was dispatched, the assignee is alive, reachable, and has
|
|
765
|
+
// demonstrably moved on to later work — an older row that has then sat unanswered past the
|
|
766
|
+
// abandon window is dead weight, not in flight. Age still gates it, so honest out-of-order
|
|
767
|
+
// completion (a seat that answered a newer job while still working an older one) is never
|
|
768
|
+
// punished — the older row stays open until the window that any genuine in-flight job would have
|
|
769
|
+
// reported within has passed.
|
|
770
|
+
const latestDirectReply = new Map();
|
|
771
|
+
for (const r of replies) {
|
|
772
|
+
if (r.ts > (latestDirectReply.get(r.from) || 0)) latestDirectReply.set(r.from, r.ts);
|
|
773
|
+
}
|
|
774
|
+
for (const c of out) {
|
|
775
|
+
if (c.answered || c.disposition === "abandoned") continue;
|
|
776
|
+
if (c.ageMs < CONTRACT_ABANDON_MS) continue;
|
|
777
|
+
const latest = latestDirectReply.get(c.to);
|
|
778
|
+
if (latest != null && c.ts < latest) c.disposition = "superseded";
|
|
779
|
+
}
|
|
755
780
|
return out;
|
|
756
781
|
}
|
|
757
782
|
|
|
@@ -1821,6 +1846,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
1821
1846
|
(t.history ||= []).push({ from: t.status, to: b.status, by: b.by || "", ts: now() });
|
|
1822
1847
|
if (t.history.length > 40) t.history.splice(0, 10);
|
|
1823
1848
|
t.status = b.status;
|
|
1849
|
+
// WHO is actually working this card — the SIGNED mover, not the assignee. A card filed by
|
|
1850
|
+
// the orchestrator and built by a seat wore the orchestrator's face on every board
|
|
1851
|
+
// (2026-08-28, operator caught it: "they all say claude"). The assignee stays intent;
|
|
1852
|
+
// workedBy is evidence, stamped only on real work moves and never from a self-asserted by.
|
|
1853
|
+
if (["doing","testing","done"].includes(b.status) && auth?.identity?.name) {
|
|
1854
|
+
t.workedBy = String(auth.identity.name).slice(0, 120);
|
|
1855
|
+
}
|
|
1824
1856
|
}
|
|
1825
1857
|
if (b.difficulty && ["easy","medium","hard"].includes(b.difficulty)) t.difficulty = b.difficulty;
|
|
1826
1858
|
if (b.model !== undefined) t.model = String(b.model).slice(0, 60);
|