trantor 0.17.30 → 0.17.32
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/agents.mjs +63 -0
- package/bin/cli.mjs +4 -0
- package/bin/gates.mjs +41 -0
- package/hooks/lib/handoff.mjs +17 -1
- package/hooks/sessionstart.mjs +25 -0
- package/hub.mjs +30 -2
- package/lib/subagent-manifest.mjs +175 -0
- package/mcp.mjs +26 -0
- package/package.json +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.32",
|
|
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/agents.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor agents [sessionId] [--json] — the LIVE sub-agent manifest for a session.
|
|
3
|
+
//
|
|
4
|
+
// What were this session's sub-agents (Agent/Task tool, Workflow swarms, agent-teams) doing —
|
|
5
|
+
// what was each tasked with, did it return, what did it write, and do those files still survive
|
|
6
|
+
// on disk? Derived fresh from the on-disk transcripts every run (so it reflects CURRENT disk,
|
|
7
|
+
// catching files an agent finished that were later clobbered — the 2026-06-21 kill corrupted a
|
|
8
|
+
// completed 30KB lib down to a 17-byte stub).
|
|
9
|
+
//
|
|
10
|
+
// trantor agents → the session of the newest handoff for THIS project (the
|
|
11
|
+
// predecessor a fresh session is taking over from)
|
|
12
|
+
// trantor agents <sessionId> → that specific session
|
|
13
|
+
// trantor agents --json → structured manifest (for tools)
|
|
14
|
+
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
15
|
+
import { join, basename } from "node:path";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { deriveSubagentManifest, formatSubagentManifest, resolveTranscriptForSid } from "../lib/subagent-manifest.mjs";
|
|
18
|
+
|
|
19
|
+
const args = process.argv.slice(2);
|
|
20
|
+
const json = args.includes("--json");
|
|
21
|
+
const sid = args.find((a) => !a.startsWith("--"));
|
|
22
|
+
|
|
23
|
+
const HANDOFF_DIR = join(process.env.RELAY_DATA_DIR || join(homedir(), ".agent-bus"), "handoffs");
|
|
24
|
+
|
|
25
|
+
// Newest handoff record whose project matches the cwd — gives us the predecessor's transcript
|
|
26
|
+
// path + project root directly (no glob needed), so `trantor agents` with no arg "just works"
|
|
27
|
+
// for a fresh session taking over.
|
|
28
|
+
function newestHandoffForCwd() {
|
|
29
|
+
try {
|
|
30
|
+
if (!existsSync(HANDOFF_DIR)) return null;
|
|
31
|
+
const cwd = process.cwd(), name = basename(cwd);
|
|
32
|
+
const recs = readdirSync(HANDOFF_DIR)
|
|
33
|
+
.filter((f) => /-\d+\.json$/.test(f))
|
|
34
|
+
.map((f) => { try { return JSON.parse(readFileSync(join(HANDOFF_DIR, f), "utf8")); } catch { return null; } })
|
|
35
|
+
.filter((r) => r && (r.project === cwd || r.projectName === name))
|
|
36
|
+
.sort((a, b) => (Number(b.stamp) || 0) - (Number(a.stamp) || 0));
|
|
37
|
+
return recs[0] || null;
|
|
38
|
+
} catch { return null; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let transcript = "", projectRoot = process.cwd();
|
|
42
|
+
if (sid) {
|
|
43
|
+
transcript = resolveTranscriptForSid(sid);
|
|
44
|
+
if (!transcript) {
|
|
45
|
+
console.error(`No transcript found for session ${sid} under ~/.claude/projects/*/.`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
const h = newestHandoffForCwd();
|
|
50
|
+
if (!h) {
|
|
51
|
+
console.error(`No handoff found for this project. Pass a session id explicitly: trantor agents <sessionId>`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
transcript = h.transcript_path || resolveTranscriptForSid(h.session_id);
|
|
55
|
+
projectRoot = h.project || projectRoot;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const manifest = deriveSubagentManifest(transcript, { projectRoot });
|
|
59
|
+
if (json) {
|
|
60
|
+
process.stdout.write(JSON.stringify(manifest, null, 2) + "\n");
|
|
61
|
+
} else {
|
|
62
|
+
process.stdout.write(formatSubagentManifest(manifest) + "\n");
|
|
63
|
+
}
|
package/bin/cli.mjs
CHANGED
|
@@ -26,6 +26,8 @@ switch (cmd) {
|
|
|
26
26
|
case "hub": run("hub.mjs"); break;
|
|
27
27
|
case "watch": run("bin/relay-watch.mjs"); break;
|
|
28
28
|
case "catchup": run("bin/catchup.mjs"); break;
|
|
29
|
+
case "agents": run("bin/agents.mjs"); break;
|
|
30
|
+
case "gates": run("bin/gates.mjs"); break;
|
|
29
31
|
case "backfill": run("bin/git-backfill.mjs"); break;
|
|
30
32
|
case "handoff": run("bin/baton.mjs"); break;
|
|
31
33
|
case "ui": {
|
|
@@ -48,6 +50,8 @@ switch (cmd) {
|
|
|
48
50
|
trantor down tear the crew down (kills processes, closes windows, no dialogs)
|
|
49
51
|
trantor ui open the live dashboard (board + flow views)
|
|
50
52
|
trantor catchup "where are we?" — the continuous board + git, with a synthesized brief
|
|
53
|
+
trantor agents what this session's sub-agents did (task · returned? · files written · survived on disk) — [<sessionId>] [--json]
|
|
54
|
+
trantor gates verification gates: "must verify before shipping" claims that survive handoffs — [--all] [--json]
|
|
51
55
|
trantor backfill card past GIT work onto the board (solo commits that were never carded) — [--since "14 days ago"] [--dry-run]
|
|
52
56
|
trantor handoff finish this session NOW: write a handoff, open a fresh session that takes over, and close this one (manual baton)
|
|
53
57
|
trantor advise ask the Advisor directly (JSON on stdin; --demo to see it)
|
package/bin/gates.mjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor gates [--all] [--json] — verification gates for THIS project: structured "must verify
|
|
3
|
+
// before shipping" claims that survive handoffs and surface to whoever takes over. Open by default;
|
|
4
|
+
// --all includes resolved ones. Set/resolve gates from inside a session via the relay_verify_gate tool.
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { resolveProject } from "../lib/project.mjs";
|
|
9
|
+
|
|
10
|
+
const args = process.argv.slice(2);
|
|
11
|
+
const all = args.includes("--all");
|
|
12
|
+
const asJson = args.includes("--json");
|
|
13
|
+
|
|
14
|
+
function relayUrl() {
|
|
15
|
+
if (process.env.RELAY_URL) return process.env.RELAY_URL;
|
|
16
|
+
try { const u = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "config.json"), "utf8")).url; if (u) return u; } catch {}
|
|
17
|
+
return "http://127.0.0.1:4477";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const project = resolveProject(process.cwd());
|
|
21
|
+
const url = `${relayUrl()}/verify-gates?project=${encodeURIComponent(project)}${all ? "&all=1" : ""}`;
|
|
22
|
+
let gates = [];
|
|
23
|
+
try {
|
|
24
|
+
const r = await fetch(url, { signal: AbortSignal.timeout(2500) });
|
|
25
|
+
gates = (await r.json()).gates || [];
|
|
26
|
+
} catch {
|
|
27
|
+
console.error(`could not reach the hub at ${relayUrl()} — is it running? (trantor setup / trantor hub)`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (asJson) { process.stdout.write(JSON.stringify(gates, null, 2) + "\n"); process.exit(0); }
|
|
32
|
+
if (!gates.length) { console.log(`${project}: no ${all ? "" : "open "}verification gates`); process.exit(0); }
|
|
33
|
+
|
|
34
|
+
console.log(`${project} — ${gates.length} ${all ? "" : "open "}verification gate(s):`);
|
|
35
|
+
for (const g of gates) {
|
|
36
|
+
const badge = g.status === "open" ? "⚠️ OPEN" : `✓ ${g.status}`;
|
|
37
|
+
console.log(`\n#${g.id} ${badge} ${g.claim}`);
|
|
38
|
+
if (g.why) console.log(` why: ${g.why}`);
|
|
39
|
+
if (g.howToVerify) console.log(` how: ${g.howToVerify}`);
|
|
40
|
+
if (g.status !== "open" && g.resolvedNote) console.log(` resolved: ${g.resolvedNote}`);
|
|
41
|
+
}
|
package/hooks/lib/handoff.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { join, basename, dirname } from "node:path";
|
|
|
15
15
|
import { homedir, hostname } from "node:os";
|
|
16
16
|
import { execSync, spawn } from "node:child_process";
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { deriveSubagentManifest } from "../../lib/subagent-manifest.mjs";
|
|
18
19
|
|
|
19
20
|
export const HANDOFF_DIR = join(process.env.RELAY_DATA_DIR || join(homedir(), ".agent-bus"), "handoffs");
|
|
20
21
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -220,6 +221,21 @@ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summa
|
|
|
220
221
|
try { gitStatus = execSync("git -C " + JSON.stringify(projectDir) + " status --short 2>/dev/null | head -30", { encoding: "utf8" }).trim(); } catch {}
|
|
221
222
|
const narrative = summary ?? buildSummary(transcript);
|
|
222
223
|
const tail = verbatimRecentTail(transcript);
|
|
224
|
+
// Sub-agent manifest SNAPSHOT (fallback). The successor should re-derive it LIVE via
|
|
225
|
+
// `trantor agents <sid>` (catches files an agent finished that were clobbered AFTER this
|
|
226
|
+
// snapshot — the kill that motivated this corrupted a completed 30KB lib post-handoff). This
|
|
227
|
+
// baked copy is just orientation if the live command isn't available. Best-effort; never throws.
|
|
228
|
+
let subagents = null;
|
|
229
|
+
try { subagents = deriveSubagentManifest(transcript, { projectRoot: projectDir }); } catch {}
|
|
230
|
+
// Open verification gates for this project — structured "must verify before shipping" claims that
|
|
231
|
+
// MUST survive the handoff (a narrative line gets skimmed past; this is what the v0.17.31 incident
|
|
232
|
+
// taught — the "verify Gail coefficients" intent vanished into prose). Fetched synchronously from
|
|
233
|
+
// the local hub; best-effort, never blocks the handoff.
|
|
234
|
+
let verifyGates = [];
|
|
235
|
+
try {
|
|
236
|
+
const out = execSync(`curl -s --max-time 2 ${JSON.stringify(relayUrl() + "/verify-gates?project=" + encodeURIComponent(projectName))}`, { encoding: "utf8", timeout: 2500 });
|
|
237
|
+
verifyGates = JSON.parse(out).gates || [];
|
|
238
|
+
} catch {}
|
|
223
239
|
const record = {
|
|
224
240
|
id: `${projectName}-${stamp}`,
|
|
225
241
|
project: projectDir, projectName, machine: hostname(),
|
|
@@ -227,7 +243,7 @@ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summa
|
|
|
227
243
|
transcript_path: transcript || "", stamp: Number(stamp) || 0,
|
|
228
244
|
// narrative + a verbatim recent-exchange block so exact in-flight state always survives
|
|
229
245
|
summary: narrative + (tail ? `\n\n---\n## Verbatim recent exchange (exact in-flight state — continue from here)\n${tail}` : ""),
|
|
230
|
-
gitStatus, consumed: false,
|
|
246
|
+
gitStatus, subagents, verifyGates, consumed: false,
|
|
231
247
|
};
|
|
232
248
|
const file = join(HANDOFF_DIR, `${record.id}.json`);
|
|
233
249
|
writeFileSync(file, JSON.stringify(record, null, 2));
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { join, basename } from "node:path";
|
|
|
11
11
|
import { homedir, hostname } from "node:os";
|
|
12
12
|
import { execSync } from "node:child_process";
|
|
13
13
|
import { resolveProject, hostId } from "../lib/project.mjs";
|
|
14
|
+
import { formatSubagentManifest } from "../lib/subagent-manifest.mjs";
|
|
14
15
|
import { updateAvailable, maybeNotifyDesktop, readConfig } from "./lib/update-check.mjs";
|
|
15
16
|
|
|
16
17
|
// Load the most recent UNCONSUMED handoff for this project (written by precompact.mjs
|
|
@@ -196,8 +197,32 @@ try {
|
|
|
196
197
|
process.stderr.write(`[trantor] ${isCompact ? "showing (not claiming, compact)" : "loaded"} pending handoff ${handoff.id}\n`);
|
|
197
198
|
additionalContext += `<trantor-handoff id="${sanitize(handoff.id)}" from="${sanitize(handoff.machine)}" trigger="${sanitize(handoff.trigger)}">\n`;
|
|
198
199
|
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`;
|
|
200
|
+
// Verification gates FIRST — these are structured "must verify before shipping" claims the prior
|
|
201
|
+
// session couldn't independently prove. They go above the summary on purpose: a safety-critical
|
|
202
|
+
// check must not be skimmed past (the lesson of the lost "verify Gail coefficients" intent).
|
|
203
|
+
if (Array.isArray(handoff.verifyGates) && handoff.verifyGates.length) {
|
|
204
|
+
additionalContext += `## ⚠️ UNVERIFIED — verify before shipping (${handoff.verifyGates.length})\n`;
|
|
205
|
+
additionalContext += `The prior session flagged these as NOT independently verified. Do NOT commit or ship the related work until each is verified (or explicitly waived WITH the user) — passing the author's own tests is not verification. Resolve via the \`relay_verify_gate\` tool (action "resolve") once checked.\n`;
|
|
206
|
+
for (const g of handoff.verifyGates) {
|
|
207
|
+
additionalContext += `- **#${sanitize(String(g.id))}: ${sanitize(g.claim)}**${g.why ? ` — ${sanitize(g.why)}` : ""}`;
|
|
208
|
+
if (g.howToVerify) additionalContext += `\n how to verify: ${sanitize(g.howToVerify)}`;
|
|
209
|
+
additionalContext += `\n`;
|
|
210
|
+
}
|
|
211
|
+
additionalContext += `\n`;
|
|
212
|
+
}
|
|
199
213
|
additionalContext += `## Handoff summary\n${sanitize(handoff.summary)}\n`;
|
|
200
214
|
if (handoff.gitStatus) additionalContext += `\n## Git working-tree at handoff\n\`\`\`\n${sanitize(handoff.gitStatus)}\n\`\`\`\n`;
|
|
215
|
+
// Sub-agent manifest: LIVE-primary, snapshot-as-fallback. The prior session may have had
|
|
216
|
+
// sub-agents (Agent/Task, Workflow) building things you can't see in its narrative — and a
|
|
217
|
+
// kill can corrupt an agent's finished file on disk. Direct the successor to re-derive LIVE
|
|
218
|
+
// (reconciles against current disk) and trust that over the baked snapshot.
|
|
219
|
+
if (handoff.subagents && handoff.subagents.counts && handoff.subagents.counts.total) {
|
|
220
|
+
const sa = handoff.subagents;
|
|
221
|
+
additionalContext += `\n## Sub-agents the prior session ran (${sa.counts.total}: ${sa.counts.completed} completed, ${sa.counts.inFlight} in-flight at handoff)\n`;
|
|
222
|
+
additionalContext += `**Before continuing, get the LIVE manifest** — run \`trantor agents ${sanitize(handoff.session_id)}\` (or \`trantor agents\` from this project). It re-derives from CURRENT disk, flagging any file an agent finished that was later clobbered — do NOT assume "nothing survived"; recover from the agent's transcript. Trust the live command over the snapshot below.\n`;
|
|
223
|
+
if (sa.counts.suspectFiles) additionalContext += `⚠️ ${sa.counts.suspectFiles} file(s) an agent wrote looked CLOBBERED at handoff time — verify with the live command and recover.\n`;
|
|
224
|
+
additionalContext += `\n\`\`\`\n${sanitize(formatSubagentManifest(sa, { heading: false }))}\n\`\`\`\n`;
|
|
225
|
+
}
|
|
201
226
|
if (handoff.transcript_path) additionalContext += `\n_Full prior transcript: ${sanitize(handoff.transcript_path)}_\n`;
|
|
202
227
|
additionalContext += `</trantor-handoff>\n`;
|
|
203
228
|
}
|
package/hub.mjs
CHANGED
|
@@ -50,11 +50,11 @@ function scanTelemetry() {
|
|
|
50
50
|
|
|
51
51
|
// peers: { session: { lastSeen, status, project } } ; tasks: kanban cards
|
|
52
52
|
// projectMeta: { project: { brief, by, updated } } — the "what & why" blurb per project
|
|
53
|
-
let state = { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], cardEvents: [], cardEventsBackfilled: false, aliases: {}, phaseMeta: {} };
|
|
53
|
+
let state = { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], cardEvents: [], cardEventsBackfilled: false, aliases: {}, phaseMeta: {}, verifyGates: [], verifyGateSeq: 0 };
|
|
54
54
|
try {
|
|
55
55
|
if (existsSync(DATA)) {
|
|
56
56
|
const loaded = JSON.parse(readFileSync(DATA, "utf8"));
|
|
57
|
-
state = { messages: loaded.messages || [], peers: {}, seq: loaded.seq || 0, tasks: loaded.tasks || [], taskSeq: loaded.taskSeq || 0, projectMeta: loaded.projectMeta || {}, lessons: loaded.lessons || [], cardEvents: Array.isArray(loaded.cardEvents) ? loaded.cardEvents : [], cardEventsBackfilled: !!loaded.cardEventsBackfilled, aliases: (loaded.aliases && typeof loaded.aliases === "object") ? loaded.aliases : {}, phaseMeta: (loaded.phaseMeta && typeof loaded.phaseMeta === "object") ? loaded.phaseMeta : {} };
|
|
57
|
+
state = { messages: loaded.messages || [], peers: {}, seq: loaded.seq || 0, tasks: loaded.tasks || [], taskSeq: loaded.taskSeq || 0, projectMeta: loaded.projectMeta || {}, lessons: loaded.lessons || [], cardEvents: Array.isArray(loaded.cardEvents) ? loaded.cardEvents : [], cardEventsBackfilled: !!loaded.cardEventsBackfilled, aliases: (loaded.aliases && typeof loaded.aliases === "object") ? loaded.aliases : {}, phaseMeta: (loaded.phaseMeta && typeof loaded.phaseMeta === "object") ? loaded.phaseMeta : {}, verifyGates: Array.isArray(loaded.verifyGates) ? loaded.verifyGates : [], verifyGateSeq: loaded.verifyGateSeq || 0 };
|
|
58
58
|
for (const [s, v] of Object.entries(loaded.peers || {})) // migrate old numeric form
|
|
59
59
|
state.peers[s] = typeof v === "number" ? { lastSeen: v, status: "", project: "" } : { lastSeen: v.lastSeen || 0, status: v.status || "", project: v.project || "" };
|
|
60
60
|
}
|
|
@@ -487,6 +487,34 @@ const server = http.createServer(async (req, res) => {
|
|
|
487
487
|
if (state.lessons.length > 500) state.lessons.splice(0, 100);
|
|
488
488
|
dirty = true; return json(res, 200, { ok: true, count: state.lessons.length });
|
|
489
489
|
}
|
|
490
|
+
// --- verification gates: structured "must verify before shipping" claims that travel with
|
|
491
|
+
// handoffs and surface PROMINENTLY to whoever takes over (so a safety-critical check can't be
|
|
492
|
+
// skimmed past in narrative prose — the "verify Gail coefficients" intent that got lost). ---
|
|
493
|
+
if (req.method === "POST" && P === "/verify-gate") {
|
|
494
|
+
const b = await body(req); touch(b.by, undefined, b.project);
|
|
495
|
+
const project = canon(String(b.project || "").slice(0, 80));
|
|
496
|
+
if (b.resolve) {
|
|
497
|
+
const g = state.verifyGates.find(x => x.id === Number(b.id) && x.project === project);
|
|
498
|
+
if (!g) return json(res, 404, { error: "gate not found" });
|
|
499
|
+
g.status = ["verified", "failed", "waived"].includes(b.status) ? b.status : "verified";
|
|
500
|
+
g.resolvedBy = b.by || ""; g.resolvedNote = String(b.note || "").slice(0, 300); g.resolvedTs = now();
|
|
501
|
+
dirty = true; return json(res, 200, { ok: true, gate: g });
|
|
502
|
+
}
|
|
503
|
+
const claim = String(b.claim || "").trim().slice(0, 300);
|
|
504
|
+
if (!claim) return json(res, 400, { error: "claim required" });
|
|
505
|
+
const dup = state.verifyGates.find(x => x.project === project && x.claim === claim && x.status === "open");
|
|
506
|
+
if (dup) return json(res, 200, { ok: true, gate: dup, dedup: true });
|
|
507
|
+
const g = { id: ++state.verifyGateSeq, project, claim, why: String(b.why || "").slice(0, 300),
|
|
508
|
+
howToVerify: String(b.howToVerify || "").slice(0, 300), status: "open", by: b.by || "", ts: now() };
|
|
509
|
+
state.verifyGates.push(g); if (state.verifyGates.length > 500) state.verifyGates.splice(0, 100);
|
|
510
|
+
dirty = true; return json(res, 200, { ok: true, gate: g });
|
|
511
|
+
}
|
|
512
|
+
if (req.method === "GET" && P === "/verify-gates") {
|
|
513
|
+
const project = canon(String(q.project || ""));
|
|
514
|
+
let gates = state.verifyGates.filter(g => !project || g.project === project);
|
|
515
|
+
if (q.all !== "1") gates = gates.filter(g => g.status === "open");
|
|
516
|
+
return json(res, 200, { gates });
|
|
517
|
+
}
|
|
490
518
|
if (req.method === "GET" && P === "/economics") { // the brain's books, surfaced: scrooge ledger + quota profile
|
|
491
519
|
const out = { scrooge: null, lifetime: null, profile: null };
|
|
492
520
|
try { out.profile = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "profile.json"), "utf8")).providers || {}; } catch {}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// trantor sub-agent manifest — reconstruct what a session's sub-agents (Agent/Task tool,
|
|
2
|
+
// Workflow swarms, agent-teams) were doing, purely from on-disk transcripts. No new runtime
|
|
3
|
+
// instrumentation: it's a READ-TIME projection of primary sources, so it can't drift and the
|
|
4
|
+
// successor can re-derive it itself (that's the point — live-primary, snapshot-as-fallback).
|
|
5
|
+
//
|
|
6
|
+
// Born from the 2026-06-21 incident: an auto baton-pass SIGKILLed a session mid 2-agent build.
|
|
7
|
+
// The fresh session had no idea two agents were even running, and one agent's COMPLETED 30KB
|
|
8
|
+
// implementation had been clobbered on disk to a 17-byte stub by the kill — so the successor
|
|
9
|
+
// rebuilt it from scratch believing "nothing survived." The manifest surfaces exactly that:
|
|
10
|
+
// what each agent was tasked with, whether it returned, what it wrote, and — via a disk
|
|
11
|
+
// reconcile — whether the files it wrote still survive or look clobbered.
|
|
12
|
+
//
|
|
13
|
+
// Four primary sources (all already on disk under ~/.claude/projects/<proj>/<sid>/):
|
|
14
|
+
// 1. subagents/*.meta.json → {name, agentType, description (the task), toolUseId}
|
|
15
|
+
// 2. parent <sid>.jsonl → tool_result ids ⇒ which agents RETURNED (completed)
|
|
16
|
+
// 3. each subagents/agent-*.jsonl → files written, last activity, the result it reported
|
|
17
|
+
// 4. disk reconcile → agent wrote X@N bytes; does X still exist & match? (suspect)
|
|
18
|
+
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
|
|
19
|
+
import { join, dirname, basename, relative } from "node:path";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
|
|
22
|
+
const EDIT_TOOLS = /^(Write|Edit|MultiEdit|NotebookEdit)$/;
|
|
23
|
+
|
|
24
|
+
function parseLines(path) {
|
|
25
|
+
let raw; try { raw = readFileSync(path, "utf8"); } catch { return []; }
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const ln of raw.split("\n")) { if (!ln) continue; try { out.push(JSON.parse(ln)); } catch {} }
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Resolve a bare session id to its transcript path by scanning ~/.claude/projects/*/<sid>.jsonl.
|
|
32
|
+
export function resolveTranscriptForSid(sid) {
|
|
33
|
+
if (!sid) return "";
|
|
34
|
+
const base = join(homedir(), ".claude", "projects");
|
|
35
|
+
try {
|
|
36
|
+
for (const proj of readdirSync(base)) {
|
|
37
|
+
const p = join(base, proj, `${sid}.jsonl`);
|
|
38
|
+
if (existsSync(p)) return p;
|
|
39
|
+
}
|
|
40
|
+
} catch {}
|
|
41
|
+
return "";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Walk subagents/ (recursing into workflows/<wf>/) collecting every *.meta.json paired with its
|
|
45
|
+
// .jsonl. `workflow` is the workflow id when the agent lives under workflows/<wf>/, else null.
|
|
46
|
+
function collectMetas(subdir) {
|
|
47
|
+
const out = [];
|
|
48
|
+
const walk = (dir, workflow) => {
|
|
49
|
+
let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
50
|
+
for (const e of entries) {
|
|
51
|
+
const p = join(dir, e.name);
|
|
52
|
+
if (e.isDirectory()) { walk(p, e.name === "workflows" ? workflow : e.name); continue; }
|
|
53
|
+
if (!e.name.endsWith(".meta.json")) continue;
|
|
54
|
+
let meta; try { meta = JSON.parse(readFileSync(p, "utf8")); } catch { continue; }
|
|
55
|
+
out.push({ ...meta, jsonlPath: p.replace(/\.meta\.json$/, ".jsonl"), workflow: workflow || null });
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
walk(subdir, null);
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function shortPath(absPath, projectRoot) {
|
|
63
|
+
try {
|
|
64
|
+
if (projectRoot && absPath.startsWith(projectRoot)) return relative(projectRoot, absPath) || absPath;
|
|
65
|
+
} catch {}
|
|
66
|
+
return absPath.split("/").slice(-4).join("/");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Does the file the agent wrote still survive intact? agentBytes = the size the agent last wrote.
|
|
70
|
+
// suspect = the file is gone, or shrank far below what the agent wrote (clobbered, e.g. by a kill).
|
|
71
|
+
function reconcileFile(absPath, agentBytes, projectRoot) {
|
|
72
|
+
let onDiskNow = null, suspect = false;
|
|
73
|
+
try {
|
|
74
|
+
if (existsSync(absPath)) {
|
|
75
|
+
onDiskNow = statSync(absPath).size;
|
|
76
|
+
if (agentBytes > 200 && onDiskNow < Math.min(agentBytes * 0.5, agentBytes - 200)) suspect = true;
|
|
77
|
+
} else {
|
|
78
|
+
onDiskNow = 0;
|
|
79
|
+
suspect = agentBytes > 200; // the agent wrote real content but the file is gone
|
|
80
|
+
}
|
|
81
|
+
} catch {}
|
|
82
|
+
return { path: shortPath(absPath, projectRoot), agentBytes, onDiskNow, suspect };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function analyzeAgent(meta, completedIds, projectRoot) {
|
|
86
|
+
const wrote = new Map(); // absPath → last-write byte length
|
|
87
|
+
let lastMs = 0, result = "";
|
|
88
|
+
for (const r of parseLines(meta.jsonlPath)) {
|
|
89
|
+
const ts = r.timestamp ? Date.parse(r.timestamp) || 0 : 0;
|
|
90
|
+
if (ts > lastMs) lastMs = ts;
|
|
91
|
+
const c = r?.message?.content;
|
|
92
|
+
if (!Array.isArray(c)) continue;
|
|
93
|
+
for (const b of c) {
|
|
94
|
+
if (b?.type === "tool_use" && EDIT_TOOLS.test(b.name) && b.input?.file_path) {
|
|
95
|
+
const content = b.input.content ?? b.input.new_string ?? "";
|
|
96
|
+
wrote.set(b.input.file_path, content.length); // last write to a path wins
|
|
97
|
+
}
|
|
98
|
+
if (b?.type === "text" && typeof b.text === "string" && b.text.trim()) result = b.text.trim();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const status = meta.toolUseId && completedIds.has(meta.toolUseId) ? "completed" : "in-flight";
|
|
102
|
+
return {
|
|
103
|
+
name: meta.name || basename(meta.jsonlPath).replace(/\.jsonl$/, ""),
|
|
104
|
+
agentType: meta.agentType || "",
|
|
105
|
+
task: meta.description || "",
|
|
106
|
+
workflow: meta.workflow || null,
|
|
107
|
+
status,
|
|
108
|
+
wrote: [...wrote.entries()].map(([p, n]) => reconcileFile(p, n, projectRoot)),
|
|
109
|
+
lastActivity: lastMs ? new Date(lastMs).toISOString() : null,
|
|
110
|
+
lastActivityMs: lastMs,
|
|
111
|
+
transcript: meta.jsonlPath,
|
|
112
|
+
result: result.slice(0, 400),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Derive the full manifest for a session from its parent transcript path. projectRoot (the repo
|
|
117
|
+
// dir) is used only to shorten displayed file paths. Returns { sessionId, subagents[], counts }.
|
|
118
|
+
export function deriveSubagentManifest(parentTranscript, { projectRoot } = {}) {
|
|
119
|
+
const out = { sessionId: "", subagents: [], counts: { total: 0, completed: 0, inFlight: 0, suspectFiles: 0 } };
|
|
120
|
+
try {
|
|
121
|
+
if (!parentTranscript || !existsSync(parentTranscript)) return out;
|
|
122
|
+
const sid = basename(parentTranscript).replace(/\.jsonl$/i, "");
|
|
123
|
+
out.sessionId = sid;
|
|
124
|
+
const subdir = join(dirname(parentTranscript), sid, "subagents");
|
|
125
|
+
if (!existsSync(subdir)) return out;
|
|
126
|
+
|
|
127
|
+
// (2) parent transcript → tool_result ids = agents that returned a result.
|
|
128
|
+
const completedIds = new Set();
|
|
129
|
+
for (const r of parseLines(parentTranscript)) {
|
|
130
|
+
const c = r?.message?.content;
|
|
131
|
+
if (Array.isArray(c)) for (const b of c) if (b?.type === "tool_result" && b.tool_use_id) completedIds.add(b.tool_use_id);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const metas = collectMetas(subdir);
|
|
135
|
+
out.subagents = metas.map((m) => analyzeAgent(m, completedIds, projectRoot))
|
|
136
|
+
.sort((a, b) => (a.lastActivityMs || 0) - (b.lastActivityMs || 0));
|
|
137
|
+
|
|
138
|
+
out.counts.total = out.subagents.length;
|
|
139
|
+
out.counts.completed = out.subagents.filter((s) => s.status === "completed").length;
|
|
140
|
+
out.counts.inFlight = out.subagents.filter((s) => s.status === "in-flight").length;
|
|
141
|
+
out.counts.suspectFiles = out.subagents.reduce((n, s) => n + s.wrote.filter((w) => w.suspect).length, 0);
|
|
142
|
+
} catch {}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Human-readable rendering (the `trantor agents` output, and the handoff snapshot block).
|
|
147
|
+
export function formatSubagentManifest(m, { heading = true } = {}) {
|
|
148
|
+
if (!m || !m.subagents.length) return "No sub-agents found for this session.";
|
|
149
|
+
const L = [];
|
|
150
|
+
if (heading) {
|
|
151
|
+
L.push(`Sub-agent manifest — ${m.counts.total} agents (${m.counts.completed} completed, ${m.counts.inFlight} in-flight at handoff)`);
|
|
152
|
+
if (m.counts.suspectFiles) {
|
|
153
|
+
L.push(`⚠️ ${m.counts.suspectFiles} file(s) an agent wrote look CLOBBERED on disk (gone or far smaller than written) — RECOVER from the agent's transcript before assuming the work was never done.`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
for (const s of m.subagents) {
|
|
157
|
+
const badge = s.status === "completed" ? "✅ completed" : "🛑 IN-FLIGHT at handoff";
|
|
158
|
+
L.push("");
|
|
159
|
+
L.push(`• ${s.name} [${s.agentType}]${s.workflow ? ` · wf:${s.workflow}` : ""} ${badge}`);
|
|
160
|
+
if (s.task) L.push(` task: ${s.task}`);
|
|
161
|
+
if (s.wrote.length) {
|
|
162
|
+
for (const w of s.wrote) {
|
|
163
|
+
const size = w.onDiskNow != null ? (w.onDiskNow === 0 && w.suspect ? "MISSING" : `${w.onDiskNow}B on disk`) : "?";
|
|
164
|
+
const flag = w.suspect ? ` ⚠️ SUSPECT — agent wrote ${w.agentBytes}B, recover from transcript` : "";
|
|
165
|
+
L.push(` wrote: ${w.path} (${size})${flag}`);
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
L.push(` wrote: (no file edits)`);
|
|
169
|
+
}
|
|
170
|
+
L.push(` transcript: ${s.transcript}`);
|
|
171
|
+
}
|
|
172
|
+
L.push("");
|
|
173
|
+
L.push(`Read any agent's transcript for its full reasoning/sourcing before trusting OR discarding its work.`);
|
|
174
|
+
return L.join("\n");
|
|
175
|
+
}
|
package/mcp.mjs
CHANGED
|
@@ -105,6 +105,32 @@ server.tool("relay_lesson", "Record a LESSON learned from a failure so future cr
|
|
|
105
105
|
return { content: [{ type: "text", text: r.dedup ? "lesson already recorded" : `lesson recorded (${r.count} total)` }] };
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
server.tool("relay_verify_gate", "Record a VERIFICATION GATE — a claim that MUST be independently verified before the related work ships (e.g. 'Gail breast coefficients match the published BCRAT model'). Unlike a note buried in a handoff narrative, a gate is STRUCTURED: it travels with handoffs and is shown PROMINENTLY to whoever takes over, so a safety-critical 'verify before commit' can't be skimmed past. action 'add' when you produce code whose correctness you have NOT independently proven (especially formulas/coefficients/security/data-shape); 'resolve' once you've verified it (or waived with the user); 'list' to see open gates. Defaults to THIS project.",
|
|
109
|
+
{ action: z.enum(["add", "resolve", "list"]).describe("add a gate · resolve one · list open gates"),
|
|
110
|
+
claim: z.string().optional().describe("what must be verified (required for add) — a specific, checkable claim"),
|
|
111
|
+
why: z.string().optional().describe("why it matters / the risk if it ships unverified"),
|
|
112
|
+
howToVerify: z.string().optional().describe("the concrete check that would verify it (source to cross-check, command to run)"),
|
|
113
|
+
id: z.number().optional().describe("gate id to resolve"),
|
|
114
|
+
status: z.string().optional().describe("resolve status: 'verified' (default) | 'failed' | 'waived'"),
|
|
115
|
+
note: z.string().optional().describe("resolution note (what you checked / why waived)"),
|
|
116
|
+
project: z.string().optional().describe("target project (default: this session's project)") },
|
|
117
|
+
async ({ action, claim, why, howToVerify, id, status, note, project }) => {
|
|
118
|
+
const proj = project || PROJECT;
|
|
119
|
+
if (action === "list") {
|
|
120
|
+
const { gates } = await api("GET", `/verify-gates?project=${encodeURIComponent(proj)}`);
|
|
121
|
+
if (!gates || !gates.length) return { content: [{ type: "text", text: `${proj}: no open verification gates` }] };
|
|
122
|
+
return { content: [{ type: "text", text: gates.map(g => `#${g.id} ⚠️ ${g.claim}${g.why ? ` — ${g.why}` : ""}`).join("\n") }] };
|
|
123
|
+
}
|
|
124
|
+
if (action === "resolve") {
|
|
125
|
+
if (!id) return { content: [{ type: "text", text: "id required to resolve a gate" }] };
|
|
126
|
+
const r = await api("POST", "/verify-gate", { resolve: true, id, status: status || "verified", note, project: proj, by: SESSION });
|
|
127
|
+
return { content: [{ type: "text", text: r.error ? `error: ${r.error}` : `gate #${id} resolved (${r.gate?.status || status || "verified"})` }] };
|
|
128
|
+
}
|
|
129
|
+
if (!claim) return { content: [{ type: "text", text: "claim required to add a gate" }] };
|
|
130
|
+
const r = await api("POST", "/verify-gate", { claim, why, howToVerify, project: proj, by: SESSION });
|
|
131
|
+
return { content: [{ type: "text", text: r.dedup ? `gate already open (#${r.gate.id})` : `🔒 verification gate #${r.gate.id} recorded — surfaces on every handoff until you resolve it` }] };
|
|
132
|
+
});
|
|
133
|
+
|
|
108
134
|
server.tool("relay_board", "Show a project's Kanban board (all cards + their status + assignee). Defaults to THIS project; pass `project` to read a crew board you orchestrate from elsewhere.",
|
|
109
135
|
{ project: z.string().optional().describe("board to show (default: this session's project)") },
|
|
110
136
|
async ({ project }) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.32",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"trantor": "bin/cli.mjs"
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"zod": "^4.4.3"
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
|
-
"test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-update.mjs"
|
|
13
|
+
"test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs"
|
|
14
14
|
},
|
|
15
15
|
"description": "The hub-world for AI agent crews — orchestrate Claude Code, Codex, Gemini, Kimi & DeepSeek as live crews with a plan-aware Advisor, a Kanban/flow command center, a testing gate, and an economics brain (Scrooge).",
|
|
16
16
|
"files": [
|