trantor 0.18.32 → 0.18.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.32",
3
+ "version": "0.18.34",
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/README.md CHANGED
@@ -399,9 +399,15 @@ trantor setup | doctor | connect | profile | provider | models
399
399
 
400
400
  - **`trantor new <name>`** — project genesis in one command: makes the dir under your dev root
401
401
  (`TRANTOR_DEV_ROOT` or `~/development`), git on main (or `--from <git-url>`, or `--adopt` an
402
- existing folder), seeds CLAUDE.md from `--brief <file>`, installs the auto-card hook, posts
403
- the brief to the hub, and opens the first card "genesis: <name>". `--json` for machines.
404
- It never spawns a session firing the crew stays your call.
402
+ existing folder), installs the auto-card hook, posts the brief to the hub, and opens the first
403
+ card "genesis: <name>". `--json` for machines. It never spawns a session — firing the crew
404
+ stays your call. Two paths in: **blank** (no brief) wakes the orchestrator plainly and you work
405
+ with it iteratively; **from a brief** (`--brief <file>`, or a PRD dropped on the app's Genesis
406
+ sheet) stores it in `docs/PRD.md` with a small CLAUDE.md pointer, and the wake runs
407
+ **`/trantor:prd-review`**: every live crew seat plus two Scrooge readers review the PRD
408
+ independently against one rubric, the orchestrator synthesizes the consensus and asks you,
409
+ the TDD gets the same review, and only then do the build cards open. A parked project with
410
+ `docs/PRD.md` and no build cards takes the same path on its next Wake.
405
411
 
406
412
  - **`trantor provider`** — `list` every crew seat (built-in + brought) with availability + tier ·
407
413
  `add <name> --key … [--plan api] [--base-url <url> --models a,b]` to bring any provider (custom
package/bin/baton.mjs CHANGED
@@ -1,16 +1,42 @@
1
1
  #!/usr/bin/env node
2
- // `trantor handoff` — one-command manual baton (auto-summary variant). Discovers the current session's
3
- // transcript, writes a whole-session handoff (auto-summary + verbatim in-flight tail), opens a fresh
4
- // self-announcing session, and closes THIS window once it takes over. Run from inside the session you
5
- // want to hand off. (The richer MODEL-authored handoff is the /trantor:handoff skill.)
6
- import { readdirSync, statSync } from "node:fs";
7
- import { join, basename } from "node:path";
2
+ // `trantor handoff` — one-command manual baton. Discovers the current session's transcript, writes
3
+ // a whole-session handoff (auto-summary + verbatim in-flight tail), opens a fresh self-announcing
4
+ // session, and closes THIS window once it takes over. Run from inside the session you want to hand
5
+ // off. (The richer MODEL-authored handoff is the /trantor:handoff skill.)
6
+ import { readdirSync, statSync, fstatSync } from "node:fs";
7
+ import { join, basename, dirname } from "node:path";
8
8
  import { homedir } from "node:os";
9
- import { resolveProject } from "../lib/project.mjs";
10
- import { writeHandoff, spawnBaton } from "../hooks/lib/handoff.mjs";
9
+ import { spawn } from "node:child_process";
10
+ import { fileURLToPath } from "node:url";
11
+ import { writeHandoff, spawnBaton, resolveHandoffSurface } from "../hooks/lib/handoff.mjs";
11
12
 
13
+ // #6074: the skill path (write-handoff.mjs) and this CLI path must share ONE resolution of which
14
+ // project this is and where the session lives. Both call resolveHandoffSurface; the name comes
15
+ // from the session's registration (TRANTOR_ORCH / RELAY_PROJECT / orch-sessions.txt) before the
16
+ // cwd — a subfolder cwd never renames the project.
12
17
  const cwd = process.cwd();
13
- const project = resolveProject(cwd);
18
+ const resolved = resolveHandoffSurface({ projectDir: process.env.CLAUDE_PROJECT_DIR || cwd, sessionId: process.env.CLAUDE_SESSION_ID || "" });
19
+ const project = resolved.project;
20
+
21
+ // Model-authored handoffs ride THIS binary too (`trantor handoff` — always the global install's
22
+ // CURRENT code, never the plugin-cache copy a session booted with, the stale-0.18.20 bug). A piped
23
+ // stdin (a heredoc, a cat, `<<HANDOFF`) means "here is the handoff markdown" — exactly the skill's
24
+ // contract — so forward to write-handoff.mjs in the SAME package (same version, same resolution)
25
+ // and exit with its status. --latest forwards too. A true pipe is a FIFO; a TTY and /dev/null are
26
+ // character devices, so a plain `trantor handoff` typed at a prompt (or run by a hook with stdin
27
+ // at /dev/null) keeps the auto-summary behavior. Detection must NOT be `!process.stdin.isTTY` —
28
+ // that misreads /dev/null as a handoff and errors where auto-summary used to work.
29
+ function stdinIsPipe() {
30
+ try { return fstatSync(0).isFIFO(); } catch { return false; }
31
+ }
32
+ if (stdinIsPipe() || process.argv.includes("--latest")) {
33
+ const helper = join(dirname(fileURLToPath(import.meta.url)), "write-handoff.mjs");
34
+ const child = spawn(process.execPath, [helper, ...process.argv.slice(2)], { stdio: ["inherit", "inherit", "inherit"] });
35
+ child.on("exit", (c) => process.exit(c ?? 1));
36
+ child.on("error", () => process.exit(1));
37
+ } else {
38
+ autoBaton();
39
+ }
14
40
 
15
41
  // The active session's transcript = newest *.jsonl directly in this project's Claude dir
16
42
  // (~/.claude/projects/<cwd-with-slashes-as-dashes>/), excluding the subagents/ subtree.
@@ -29,20 +55,22 @@ function findTranscript() {
29
55
  return best;
30
56
  }
31
57
 
32
- const transcript = findTranscript();
33
- // The transcript's filename IS the writing session's id — record it, or an orchestrator-thread
34
- // handoff carries no writer and the baton-hold + map-follow logic in sessionstart.mjs can't fire.
35
- const sessionId = transcript ? basename(transcript, ".jsonl") : "";
36
- const { file } = writeHandoff({ projectDir: cwd, sessionId, transcript, trigger: "manual-cli", force: true }); // manual = intentional, bypass the storm guard
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);
58
+ function autoBaton() {
59
+ const transcript = findTranscript();
60
+ // The transcript's filename IS the writing session's id record it, or an orchestrator-thread
61
+ // handoff carries no writer and the baton-hold + map-follow logic in sessionstart.mjs can't fire.
62
+ const sessionId = transcript ? basename(transcript, ".jsonl") : "";
63
+ const { file } = writeHandoff({ projectDir: cwd, sessionId, transcript, trigger: "manual-cli", force: true, projectName: project }); // manual = intentional, bypass the storm guard
64
+ console.log(`📋 handoff saved for ${project}: ${file}`);
65
+ // --write-only: the in-app flow (#5509). The app ends the pane's session itself and reopens it
66
+ // through `trantor open`, which claims this handoff a Terminal window here would be exactly the
67
+ // wrong surface, so the flag writes, announces, and stops.
68
+ if (process.argv.includes("--write-only")) {
69
+ console.log(`🔄 write-only: no window spawned — the pane takeover (trantor open) claims it next.`);
70
+ process.exit(0);
71
+ }
72
+ const { spawned, armed, windowId } = spawnBaton({ projectDir: cwd, handoffFile: file });
73
+ console.log(spawned
74
+ ? `🔄 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"}`
75
+ : `handoff saved, but couldn't spawn a fresh session (non-macOS or spawn disabled) — open a new session here to take over`);
44
76
  }
45
- const { spawned, armed, windowId } = spawnBaton({ projectDir: cwd, handoffFile: file });
46
- console.log(spawned
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"}`
48
- : `handoff saved, but couldn't spawn a fresh session (non-macOS or spawn disabled) — open a new session here to take over`);
package/bin/cli.mjs CHANGED
@@ -70,6 +70,7 @@ switch (cmd) {
70
70
  case "reconcile": run("bin/reconcile.mjs"); break;
71
71
  case "init-hooks": run("bin/init-hooks.mjs"); break;
72
72
  case "new": run("bin/new.mjs"); break;
73
+ case "genesis-kickoff": run("bin/genesis-kickoff.mjs"); break;
73
74
  case "balances": case "balance": case "credits": run("bin/balances.mjs"); break;
74
75
  case "recost": run("bin/recost.mjs"); break;
75
76
  case "handoff": run("bin/baton.mjs"); break;
@@ -209,7 +210,8 @@ switch (cmd) {
209
210
  trantor gates verification gates: "must verify before shipping" claims that survive handoffs — [--all] [--json]
210
211
  trantor backfill card past GIT work onto the board (solo commits that were never carded) — [--since "14 days ago"] [--dry-run]
211
212
  trantor init-hooks install a git post-commit hook so EVERY commit auto-cards on the board (reliable solo-work backstop) — [--uninstall]
212
- trantor new project genesis: new <name> [--from <git-url>] [--brief <file>] [--dir <parent>] [--adopt] [--json] — creates <parent>/<name>, git main, CLAUDE.md from the brief, hooks, hub brief + first card (never spawns a session)
213
+ trantor new project genesis: new <name> [--from <git-url>] [--brief <file>] [--dir <parent>] [--adopt] [--json] — creates <parent>/<name>, git main, docs/PRD.md + small CLAUDE.md, hooks, hub brief + first card (never spawns a session)
214
+ trantor genesis-kickoff internal wake prompt selector from the checkout + signed project board
213
215
  trantor balances how much credit is left on each CONFIGURED provider (from your profile) — refill before you stall — [--json]
214
216
  trantor recost recompute sub-agent notional cost from on-disk transcripts + reseed the board (repair after upgrade) — [--dry-run]
215
217
  trantor handoff finish this session NOW: write a handoff, open a fresh session that takes over, and close this one (manual baton)
package/bin/connect.mjs CHANGED
@@ -40,6 +40,10 @@ function patchJson(path, mutate) {
40
40
  // while its runner sat on the pinned one — the residual split-brain mechanism (2026-08-20).
41
41
  // mcp.mjs resolves the hub from the session's project pin; that resolution must stay in charge.
42
42
  const relayEnv = (agent) => ({ RELAY_AGENT: agent });
43
+ // OpenCode hosts several differently-named seats. Its global MCP environment must not stamp all
44
+ // of them "opencode": ambient runner identity wins, while this fallback names a normal interactive
45
+ // OpenCode session that has no RELAY_AGENT/RELAY_SESSION of its own.
46
+ const hostedRelayEnv = (agent) => ({ RELAY_AGENT_FALLBACK: agent });
43
47
 
44
48
  // ---- Claude Code: plugin handles it; verify only ----
45
49
  if (has("claude")) {
@@ -87,7 +91,13 @@ if (has("opencode")) {
87
91
  report("opencode", patchJson(p, d => {
88
92
  d.$schema ||= "https://opencode.ai/config.json";
89
93
  d.mcp ||= {};
90
- d.mcp.relay ||= { type: "local", command: ["node", MCP], enabled: true, environment: relayEnv("opencode") };
94
+ d.mcp.relay ||= { type: "local", command: ["node", MCP], enabled: true };
95
+ d.mcp.relay.environment ||= {};
96
+ // Migrate the old generated pin too: `||=` alone left RELAY_AGENT=opencode in every existing
97
+ // config forever, where OpenCode overlaid it on the qwen/glm/deepseek runner environment.
98
+ delete d.mcp.relay.environment.RELAY_AGENT;
99
+ delete d.mcp.relay.environment.RELAY_SESSION;
100
+ Object.assign(d.mcp.relay.environment, hostedRelayEnv("opencode"));
91
101
  }), p);
92
102
  }
93
103
 
@@ -358,7 +358,7 @@ async function reportFailure(exit, trigger, undelivered = 0) {
358
358
  const reason = classify(exit);
359
359
  const down = consecFails >= 2;
360
360
  const status = down ? `down: ${reason} · ${consecFails} fails` : `errored: ${reason}`;
361
- await api("/register", { session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL }).catch(() => {});
361
+ await api("/register", { session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
362
362
  const hint = reason === "exhausted" ? " — needs `trantor swap`"
363
363
  : reason === "auth" ? " — check credentials"
364
364
  : reason === "backend-error" ? " — provider backend error (NOT quota): retry, or `trantor swap` to another provider"
@@ -446,7 +446,7 @@ async function reportHealthy() {
446
446
  consecFails = 0;
447
447
  // Recovery is a change too, so the next failure is news again.
448
448
  announced = "";
449
- await api("/register", { session: SESSION, project: PROJ, status: `active in ${PROJ}`, llm: AGENT, model: MODEL }).catch(() => {});
449
+ await api("/register", { session: SESSION, project: PROJ, status: `active in ${PROJ}`, llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
450
450
  await api("/send", { from: SESSION, to: "all", text: `✅ ${SESSION} recovered`, project: PROJ, kind: "status" }).catch(() => {});
451
451
  cmuxStatus("ok", "#14b8a6", "check"); herdrAgent("idle");
452
452
  }
@@ -510,9 +510,13 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
510
510
  { detached: true, stdio: "ignore" });
511
511
  wd.unref();
512
512
  } catch {}
513
- const r = spawnSync("/bin/bash", ["-c", `set -o pipefail; { ${inner} ; } 2> >(${SCRUB} --tee2 ${ERRF})`], {
513
+ // Preserve the CLI's exit before waiting for the stderr process substitution. Without the
514
+ // explicit wait, a short failing CLI can return while its error is still in the scrub pipe;
515
+ // under load the classifier then reads an empty ERRF and reports the wrong failure reason.
516
+ const shell = `set -o pipefail; { ${inner} ; } 2> >(${SCRUB} --tee2 ${ERRF}); turn_exit=$?; wait; exit $turn_exit`;
517
+ const r = spawnSync("/bin/bash", ["-c", shell], {
514
518
  cwd: TURN_DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : "inherit",
515
- env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_PROJECT: PROJ,
519
+ env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_SESSION: SESSION, RELAY_PROJECT: PROJ,
516
520
  // A RUNNER-MANAGED SEAT MUST NEVER HAND ITSELF A BATON.
517
521
  //
518
522
  // The handoff machinery exists for an INTERACTIVE session: near its context limit it writes a
@@ -527,9 +531,8 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
527
531
  maxBuffer: 16 * 1024 * 1024,
528
532
  });
529
533
  try { unlinkSync(STAMPF); } catch {} // turn over — disarm the watchdog
530
- // #5869: scrub AT REST, synchronously, before anything reads the file back. The stderr hop is
531
- // a process substitution bash does not wait for, so this pass also catches its tail the
532
- // auth classifier and the empty-output check below must judge REDACTED text and a settled file.
534
+ // #5869: scrub AT REST, synchronously, before anything reads the file back. The explicit shell
535
+ // wait above drains the live stderr scrubber first; this pass is defense in depth for redaction.
533
536
  try { writeFileSync(ERRF, redactKeys(readFileSync(ERRF, "utf8"))); } catch {}
534
537
  // #5868: classify only what the CLI itself said. The transcript replays the whole turn prompt
535
538
  // (rules, lessons, the wake text) — and those lines once classified healthy codex turns as
@@ -673,7 +676,11 @@ function askedExcerpt(message) {
673
676
  // start cursor at the CURRENT tip so we don't replay history
674
677
  let cursor = 0;
675
678
  try { const r = await api(`/inbox?session=${encodeURIComponent(SESSION)}&since=0`); cursor = r.cursor || 0; } catch {}
676
- await api("/register", { session: SESSION, project: PROJ, status: "crew member booting", llm: AGENT, model: MODEL }).catch(() => {});
679
+ // kind "agent" on every beat (#6075): the peer row's kind is the hub's OWN record of what a
680
+ // session is — the overseer's declared-crew exemption reads it, and on the remote hub there is
681
+ // no crew-windows.txt to fall back to. /register preserves absent fields, so a seat running an
682
+ // older runner never loses a kind an updated one stamped.
683
+ await api("/register", { session: SESSION, project: PROJ, status: "crew member booting", llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
677
684
  // Announce runner-side, signed as THIS seat. Asking the seat to announce itself sent glm's hello
678
685
  // out under deepseek's identity whenever opencode seats shared one MCP daemon (lesson on the bus,
679
686
  // 2026-07-29): the runner process is per-seat by construction, so its signature cannot be borrowed.
@@ -726,7 +733,12 @@ function askedExcerpt(message) {
726
733
  let msgs = [];
727
734
  try {
728
735
  const r = await api(`/poll?session=${encodeURIComponent(SESSION)}&since=${cursor}&wait=${holdS}`);
729
- msgs = r.messages || []; cursor = r.cursor ?? cursor;
736
+ msgs = r.messages || [];
737
+ if (r.cursor !== undefined && r.cursor !== null && Number.isFinite(Number(r.cursor))) {
738
+ const reportedCursor = Number(r.cursor);
739
+ if (reportedCursor < cursor) log(`cursor rewound by hub ${cursor} -> ${reportedCursor}`);
740
+ cursor = reportedCursor;
741
+ }
730
742
  } catch (e) {
731
743
  // Deadline-abort on the LONG-POLL is not an outage — it means the hold expired with no hub
732
744
  // response (stalled event loop, napped machine, dead socket). Reconnect immediately and say
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ // trantor genesis-kickoff [<project>] — which boot prompt a WOKEN orchestrator gets (#6112).
3
+ //
4
+ // Two paths into a project (operator ruling 2026-09-02 23:15). Path A, blank: the orchestrator
5
+ // wakes plainly and works iteratively with the operator. Path B, from a brief: the PRD sits in
6
+ // docs/PRD.md and the wake CONVENES the crew review (/trantor:prd-review) before anything is
7
+ // built. Waking an adopted project that has docs/PRD.md and no build cards takes path B too, so
8
+ // a project that parked with its PRD in place needs no re-genesis: the next Wake convenes it.
9
+ //
10
+ // The decision needs two facts only the CLI holds together — the checkout's durable docs/PRD.md
11
+ // and the project's SIGNED board — so it lives here. The desktop app (genesis sheet, sidebar
12
+ // Wake, workspace open) runs this in the checkout and relays the one line it prints; on exit 1
13
+ // the app types its own plain wake instead. A board that cannot be read therefore fails CLOSED
14
+ // to the plain wake, never to a review nobody verified was due: the plain-woken orchestrator
15
+ // still sees docs/PRD.md and the CLAUDE.md pointer and can convene by hand.
16
+ import { existsSync } from "node:fs";
17
+ import { join, resolve } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { loadIdentity, signedGet } from "../hooks/lib/api.mjs";
20
+ import { ensureEnrolled as enrollViaOwnerInvite } from "../lib/enroll.mjs";
21
+ import { hostId, resolveHub, resolveProject } from "../lib/project.mjs";
22
+
23
+ export const PRD_REVIEW_KICKOFF = "docs/PRD.md is the brief; run /trantor:prd-review";
24
+ // Word for word the app's WAKE_KICKOFF_PROMPT (desktop terminal.rs): a project without a brief to
25
+ // review gets exactly the wake it always got.
26
+ export const PLAIN_WAKE_KICKOFF = "You were just woken via Trantor. Catch up from your context — the handoff you were handed if one exists, otherwise the project board and memory — then recap where things stand in at most 3 sentences and wait.";
27
+
28
+ // Cards that exist BEFORE a build starts, and must not be mistaken for one: the genesis card
29
+ // `trantor new` opens, the review cards this flow opens, and the auto-cards a session sheds
30
+ // (operator prompts, sub-agents) which say a conversation happened, not that work was cut.
31
+ const PRE_BUILD_PHASES = new Set(["genesis", "prd", "tdd"]);
32
+ const PRE_BUILD_TITLE = /^(genesis:|prd review:|tdd review:)/i;
33
+ const CONVERSATION_SOURCES = new Set(["session", "cc-subagent", "cc-bg-agent"]);
34
+
35
+ export function isBuildCard(task) {
36
+ if (!task || typeof task !== "object") return false;
37
+ const phase = String(task.phase || "").trim().toLowerCase();
38
+ if (phase === "build") return true;
39
+ if (PRE_BUILD_PHASES.has(phase)) return false;
40
+ if (CONVERSATION_SOURCES.has(String(task.source || "").trim().toLowerCase())) return false;
41
+ const title = String(task.title || "").trim();
42
+ return Boolean(title) && !PRE_BUILD_TITLE.test(title);
43
+ }
44
+
45
+ // The pure decision: dir = the checkout, tasks = the board (an array), or null when it could not
46
+ // be read. Exported so the drill can pin every branch without a hub.
47
+ export function selectGenesisKickoff({ dir, tasks }) {
48
+ if (!existsSync(join(dir, "docs", "PRD.md"))) return PLAIN_WAKE_KICKOFF;
49
+ if (!Array.isArray(tasks)) return PLAIN_WAKE_KICKOFF;
50
+ return tasks.some(isBuildCard) ? PLAIN_WAKE_KICKOFF : PRD_REVIEW_KICKOFF;
51
+ }
52
+
53
+ async function readBoard(project) {
54
+ const hub = resolveHub(project);
55
+ // Sign as the identity the orchestrator in this checkout will use (RELAY_SESSION when a runner
56
+ // set one, else host:project). On an enforce hub that identity may be brand new for a project
57
+ // `trantor new` made seconds ago, and TOFU is refused there — so enrol the way crew seats and
58
+ // genesis itself do: the operator's owner key mints a project-scoped invite and this identity
59
+ // spends it. A no-op when the hub already knows us; a soft failure when it does not, in which
60
+ // case the signed read below reports the refusal and the caller falls back.
61
+ const session = process.env.RELAY_SESSION
62
+ || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
63
+ const identity = loadIdentity(session);
64
+ const enrolment = await enrollViaOwnerInvite(hub, identity, project, { timeoutMs: 4000 });
65
+ if (!enrolment.ok && enrolment.reason !== "no-owner-key") {
66
+ console.error(`genesis kickoff: enrolment on ${hub} did not succeed (${enrolment.reason}); trying the read anyway`);
67
+ }
68
+ const response = await signedGet(`/tasks?project=${encodeURIComponent(project)}`, { session, project, timeoutMs: 4000 });
69
+ if (!response.ok) {
70
+ return { ok: false, hub, reason: response.status ? `hub ${response.status}${response.json?.error ? `: ${response.json.error}` : ""}` : "unreachable" };
71
+ }
72
+ const tasks = Array.isArray(response.json) ? response.json : response.json?.tasks;
73
+ return Array.isArray(tasks) ? { ok: true, hub, tasks } : { ok: false, hub, reason: "malformed /tasks response" };
74
+ }
75
+
76
+ async function main() {
77
+ const dir = process.cwd();
78
+ const project = process.argv[2] || resolveProject(dir);
79
+ if (!existsSync(join(dir, "docs", "PRD.md"))) {
80
+ console.log(PLAIN_WAKE_KICKOFF);
81
+ return;
82
+ }
83
+ const board = await readBoard(project);
84
+ if (!board.ok) {
85
+ console.error(`genesis kickoff: docs/PRD.md is present but ${project}'s board on ${board.hub} could not be read (${board.reason}) — the app falls back to the plain wake`);
86
+ process.exitCode = 1;
87
+ return;
88
+ }
89
+ console.log(selectGenesisKickoff({ dir, tasks: board.tasks }));
90
+ }
91
+
92
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
package/bin/new.mjs CHANGED
@@ -6,8 +6,8 @@
6
6
  // It makes the project directory at <parent>/<name> — --dir names the PARENT, never the project
7
7
  // directory itself (default parent: TRANTOR_DEV_ROOT or ~/development). The name is always
8
8
  // appended under it, so `--dir P` with name N creates P/N. Starts git on main (or clones --from,
9
- // or adopts an existing folder with --adopt), seeds CLAUDE.md from the
10
- // brief (verbatim brief + the trantor conventions block), installs the same auto-card hook as
9
+ // or adopts an existing folder with --adopt), stores the brief in docs/PRD.md and seeds a small
10
+ // CLAUDE.md pointer plus the trantor conventions block, installs the same auto-card hook as
11
11
  // `trantor init-hooks`, posts the brief as the hub project brief (POST /project — the same call
12
12
  // relay_project_brief makes), and opens the first card "genesis: <name>" on the new board.
13
13
  //
@@ -17,7 +17,7 @@
17
17
  import { spawnSync } from "node:child_process";
18
18
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, appendFileSync } from "node:fs";
19
19
  import { homedir } from "node:os";
20
- import { dirname, isAbsolute, join, resolve } from "node:path";
20
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
21
21
  import { fileURLToPath } from "node:url";
22
22
  import { ensureEnrolled as enrollTofu, loadIdentity, signedPost } from "../hooks/lib/api.mjs";
23
23
  import { ensureEnrolled as enrollViaOwnerInvite } from "../lib/enroll.mjs";
@@ -28,7 +28,7 @@ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
28
28
 
29
29
  // The trantor conventions block — what every trantor-wired project's CLAUDE.md carries so the
30
30
  // first session knows the board, the crew, and the gates exist. Kept SHORT and factual; the
31
- // brief above it is the project's own voice.
31
+ // project brief stays in docs/PRD.md so a large PRD cannot exceed the harness instruction limit.
32
32
  const CONVENTIONS = [
33
33
  "",
34
34
  "## Trantor conventions",
@@ -94,13 +94,22 @@ if (from) {
94
94
  branch = git(["branch", "--show-current"]).stdout.trim() || "main";
95
95
  }
96
96
 
97
- // ── CLAUDE.md — verbatim brief + the conventions block ──────────────────────────────────────────
97
+ // ── durable brief + small CLAUDE.md pointer ─────────────────────────────────────────────────────
98
+ if (brief) {
99
+ const docs = join(dir, "docs");
100
+ mkdirSync(docs, { recursive: true });
101
+ writeFileSync(join(docs, "PRD.md"), `${brief}\n`);
102
+ }
103
+
98
104
  const claude = join(dir, "CLAUDE.md");
99
105
  if (existsSync(claude)) {
100
106
  const current = readFileSync(claude, "utf8");
101
107
  if (!current.includes("## Trantor conventions")) appendFileSync(claude, `\n${CONVENTIONS}\n`);
102
108
  } else {
103
- const head = brief ? `${brief}\n` : `# ${name}\n\n(Genesis no brief was given. Add this project's what/why/goal here.)\n`;
109
+ const importedFrom = briefFile ? ` (imported from \`${basename(briefFile)}\`)` : "";
110
+ const head = brief
111
+ ? `# ${name}\n\nThe project brief is stored at \`docs/PRD.md\`${importedFrom}. Read and maintain it there.\n`
112
+ : `# ${name}\n\nNo project brief was supplied. Begin with the operator's first instruction.\n`;
104
113
  writeFileSync(claude, `${head}${CONVENTIONS}`);
105
114
  }
106
115
 
@@ -129,9 +138,14 @@ try {
129
138
  // way crew seats do: the operator's owner key mints a project-scoped write invite and the genesis
130
139
  // identity spends it. Only when NO owner key is configured (a loopback hub with no owner identity)
131
140
  // do we fall back to the plain TOFU enroll.
132
- const viaOwner = await enrollViaOwnerInvite(hub, identity, name, { timeoutMs: 8000 });
133
- if (!viaOwner.ok && viaOwner.reason === "no-owner-key") await enrollTofu(session, identity, name);
141
+ const viaOwner = await enrollViaOwnerInvite(hub, identity, name, { timeoutMs: 8000, kind: "tool" });
142
+ if (!viaOwner.ok && viaOwner.reason === "no-owner-key") await enrollTofu(session, identity, name, { kind: "tool" });
134
143
  else if (!viaOwner.ok) console.error(`genesis: enrollment via owner invite failed: ${viaOwner.reason}`);
144
+ // #6068: say WHAT this session is. The genesis identity exists to post the brief — without a
145
+ // kind on its peer row the app's seat strip renders it as a seat ("no terminal pane — start it
146
+ // with trantor up genesis"). /register is presence, not speech: no message rides it, nobody
147
+ // wakes. The hub stamps kind on the session row and /peers returns it to the app.
148
+ await signedPost("/register", { session, kind: "tool" }, { session, project: name, timeoutMs: 8000 }).catch(() => {});
135
149
  const briefForHub = (brief || `Genesis of ${name} — created by trantor new.`).slice(0, 600);
136
150
  const r1 = await signedPost("/project", { project: name, brief: briefForHub, by: session }, { session, project: name, timeoutMs: 8000 });
137
151
  if (!r1.ok) throw new Error(`hub ${r1.status} on /project${r1.json?.error ? `: ${r1.json.error}` : ""}`);
@@ -156,7 +170,9 @@ if (json) {
156
170
  console.log(JSON.stringify({ name, parent: devRoot, dir, branch, hub, card }));
157
171
  } else {
158
172
  console.log(`✓ ${dir} (${branch}${from ? ", cloned" : adopt ? ", adopted" : ""})`);
159
- console.log(`✓ CLAUDE.md seeded${brief ? " from the brief" : " (no brief — add the project's what/why/goal)"}`);
173
+ console.log(brief
174
+ ? "✓ docs/PRD.md seeded from the brief; CLAUDE.md kept small"
175
+ : "✓ blank project seeded; CLAUDE.md kept small");
160
176
  console.log(`✓ auto-card hook installed (trantor init-hooks)`);
161
177
  if (card !== null) console.log(`✓ hub ${hub}: brief posted, card #${card} ("genesis: ${name}")`);
162
178
  else if (hubError) console.log(`! hub ${hub}: brief/card not posted (${hubError})`);
@@ -6,7 +6,7 @@ import { writeFileSync, readFileSync, existsSync, mkdirSync, readdirSync } from
6
6
  import { join, basename } from "node:path";
7
7
  import { homedir, hostname } from "node:os";
8
8
  import { execSync } from "node:child_process";
9
- import { spawnBaton, handoffMode } from "../hooks/lib/handoff.mjs";
9
+ import { spawnBaton, handoffMode, resolveHandoffSurface } from "../hooks/lib/handoff.mjs";
10
10
  import { handoffDir } from "../lib/project.mjs";
11
11
 
12
12
  const baton = process.argv.includes("--baton");
@@ -14,8 +14,14 @@ const baton = process.argv.includes("--baton");
14
14
  // fresh one on stdin", so a session that had just written a 5KB handoff had to write it again to
15
15
  // hand it over — 2m28s of regenerated prose on a live scribe session, 2026-08-24.
16
16
  const latest = process.argv.includes("--latest");
17
- const project = process.env.CLAUDE_PROJECT_DIR || process.cwd();
18
- const name = basename(project);
17
+ // #6074: ONE resolver for which project this is and where the session lives — shared with
18
+ // bin/baton.mjs (the `trantor handoff` CLI path) so the two cannot diverge. The name comes from
19
+ // the session's registration (TRANTOR_ORCH / RELAY_PROJECT / orch-sessions.txt) before the cwd;
20
+ // the witnessed crebral-scribe/ios handoff recorded project "ios" from a subfolder cwd and never
21
+ // found its pane.
22
+ const resolved = resolveHandoffSurface({ sessionId: process.env.CLAUDE_SESSION_ID || "" });
23
+ const project = resolved.projectDir;
24
+ const name = resolved.project;
19
25
  let summary = "";
20
26
  if (!latest) {
21
27
  process.stdin.setEncoding("utf8");
@@ -0,0 +1,50 @@
1
+ #!/bin/bash
2
+ # Guarded production restart: an unhealthy durable writer means RAM contains the only current copy.
3
+ set -euo pipefail
4
+
5
+ FORCE=0
6
+ if [ "${1:-}" = "--force" ]; then
7
+ FORCE=1
8
+ shift
9
+ fi
10
+ if [ "$#" -ne 0 ]; then
11
+ echo "usage: deploy/restart-hub.sh [--force]" >&2
12
+ exit 2
13
+ fi
14
+
15
+ # The hub binds RELAY_HOST (the tailnet IP on netcup, where 127.0.0.1 refuses), so the guard reads the
16
+ # same env the service does; RELAY_HUB_URL still overrides.
17
+ ENV_FILE="${RELAY_ENV_FILE:-$(dirname "$0")/hub.env}"
18
+ if [ -f "$ENV_FILE" ]; then
19
+ # shellcheck disable=SC1090
20
+ set -a; . "$ENV_FILE"; set +a
21
+ fi
22
+ HUB_URL="${RELAY_HUB_URL:-http://${RELAY_HOST:-127.0.0.1}:${RELAY_PORT:-4477}}"
23
+ HEALTH=""
24
+ if HEALTH="$(curl --fail --silent --show-error --max-time 5 "$HUB_URL/health")"; then
25
+ REFUSAL="$(node --input-type=module -e '
26
+ const chunks = [];
27
+ for await (const chunk of process.stdin) chunks.push(chunk);
28
+ let health;
29
+ try { health = JSON.parse(Buffer.concat(chunks).toString("utf8")); }
30
+ catch { process.stdout.write("hub health response was not valid JSON"); process.exit(0); }
31
+ const p = health.persist;
32
+ if (!p || p.ok !== false) process.exit(0);
33
+ const age = Number(p.failingSinceMs || 0);
34
+ const span = age >= 60000 ? `${Math.floor(age / 60000)}m ${Math.floor((age % 60000) / 1000)}s` : `${Math.floor(age / 1000)}s`;
35
+ process.stdout.write(`hub persistence has failed for ${span} (${Number(p.retries || 0)} retries): ${String(p.lastError || "unknown error")}. Restarting risks losing every state change since persistence stopped.`);
36
+ ' <<<"$HEALTH")"
37
+ if [ -n "$REFUSAL" ] && [ "$FORCE" -ne 1 ]; then
38
+ echo "REFUSED: $REFUSAL" >&2
39
+ echo "Resolve persistence first, or rerun with --force to accept the data-loss risk." >&2
40
+ exit 1
41
+ fi
42
+ if [ -n "$REFUSAL" ]; then
43
+ echo "WARNING: --force accepted: $REFUSAL" >&2
44
+ fi
45
+ else
46
+ echo "WARNING: $HUB_URL/health was unreachable; proceeding because no running hub state can be inspected." >&2
47
+ fi
48
+
49
+ systemctl restart trantor-hub
50
+ echo "restarted trantor-hub"
@@ -72,10 +72,15 @@ echo " schema applied"
72
72
  # systemd unit
73
73
  cp deploy/trantor-hub.service /etc/systemd/system/trantor-hub.service
74
74
  systemctl daemon-reload
75
- systemctl enable --now trantor-hub
75
+ systemctl enable trantor-hub
76
+ if systemctl is-active --quiet trantor-hub; then
77
+ bash deploy/restart-hub.sh
78
+ else
79
+ systemctl start trantor-hub
80
+ fi
76
81
 
77
82
  # crons: daily backup + nightly retention
78
- chmod +x deploy/backup.sh deploy/retention.sh deploy/restore.sh
83
+ chmod +x deploy/backup.sh deploy/retention.sh deploy/restore.sh deploy/restart-hub.sh
79
84
  echo "0 2 * * * root /opt/trantor/deploy/backup.sh" > /etc/cron.d/trantor
80
85
  echo "0 3 * * * root /opt/trantor/deploy/retention.sh" >> /etc/cron.d/trantor
81
86
 
package/hooks/lib/api.mjs CHANGED
@@ -112,7 +112,7 @@ function enrolledPath(session) {
112
112
  const busDir = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
113
113
  return join(busDir, "keys", `${String(session).replace(/[^A-Za-z0-9_.-]/g, "_")}.enrolled`);
114
114
  }
115
- export async function ensureEnrolled(session, identity, project) {
115
+ export async function ensureEnrolled(session, identity, project, { kind = "agent" } = {}) {
116
116
  if (!identity?.pubkey) return;
117
117
  const hub = relayUrl(project);
118
118
  const stamp = enrolledPath(session);
@@ -126,7 +126,7 @@ export async function ensureEnrolled(session, identity, project) {
126
126
  // payload, sets content-type, and signs — so every hook signs identically with zero hand-rolling.
127
127
  const r = await sfetchJson(`${hub}/enroll`, {
128
128
  method: "POST",
129
- payload: { pubkey: identity.pubkey, name: session, kind: "agent" },
129
+ payload: { pubkey: identity.pubkey, name: session, kind },
130
130
  identity,
131
131
  signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
132
132
  });