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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +9 -3
- package/bin/baton.mjs +53 -25
- package/bin/cli.mjs +3 -1
- package/bin/connect.mjs +11 -1
- package/bin/crew-runner.mjs +21 -9
- package/bin/genesis-kickoff.mjs +92 -0
- package/bin/new.mjs +25 -9
- package/bin/write-handoff.mjs +9 -3
- package/deploy/restart-hub.sh +50 -0
- package/deploy/setup-netcup.sh +7 -2
- package/hooks/lib/api.mjs +2 -2
- package/hooks/lib/handoff.mjs +78 -7
- package/hooks/sessionstart.mjs +9 -2
- package/hooks/stop-inbox.mjs +41 -4
- package/hub.mjs +188 -23
- package/lib/enroll.mjs +2 -2
- package/lib/persist-health.mjs +56 -0
- package/lib/same-project.mjs +65 -0
- package/lib/store-pg.mjs +20 -2
- package/mcp.mjs +5 -3
- package/package.json +2 -2
- package/skills/handoff/SKILL.md +14 -1
- package/skills/prd-review/SKILL.md +159 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
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),
|
|
403
|
-
|
|
404
|
-
|
|
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
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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 {
|
|
10
|
-
import {
|
|
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
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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,
|
|
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
|
|
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
|
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
531
|
-
//
|
|
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
|
-
|
|
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 || [];
|
|
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),
|
|
10
|
-
//
|
|
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
|
|
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
|
-
// ──
|
|
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
|
|
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(
|
|
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})`);
|
package/bin/write-handoff.mjs
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
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"
|
package/deploy/setup-netcup.sh
CHANGED
|
@@ -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
|
|
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
|
|
129
|
+
payload: { pubkey: identity.pubkey, name: session, kind },
|
|
130
130
|
identity,
|
|
131
131
|
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
|
132
132
|
});
|