trantor 0.18.28 → 0.18.30

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/README.md CHANGED
@@ -393,10 +393,16 @@ rate, not work rate.
393
393
 
394
394
  ```
395
395
  trantor setup | doctor | connect | profile | provider | models
396
- | up <agents…> | swap <old> <new> | down | seat-why <agent> | ui | advise | hub | watch
396
+ | new <name> | up <agents…> | swap <old> <new> | down | seat-why <agent> | ui | advise | hub | watch
397
397
  | adopt <project> | reconcile | duty | orchestrate | patrol | app | backfill | init-hooks
398
398
  ```
399
399
 
400
+ - **`trantor new <name>`** — project genesis in one command: makes the dir under your dev root
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.
405
+
400
406
  - **`trantor provider`** — `list` every crew seat (built-in + brought) with availability + tier ·
401
407
  `add <name> --key … [--plan api] [--base-url <url> --models a,b]` to bring any provider (custom
402
408
  endpoints are wired into OpenCode for you) · `remove <name>`.
package/bin/cli.mjs CHANGED
@@ -69,6 +69,7 @@ switch (cmd) {
69
69
  case "sweep": run("bin/sweep.mjs"); break;
70
70
  case "reconcile": run("bin/reconcile.mjs"); break;
71
71
  case "init-hooks": run("bin/init-hooks.mjs"); break;
72
+ case "new": run("bin/new.mjs"); break;
72
73
  case "balances": case "balance": case "credits": run("bin/balances.mjs"); break;
73
74
  case "recost": run("bin/recost.mjs"); break;
74
75
  case "handoff": run("bin/baton.mjs"); break;
@@ -208,6 +209,7 @@ switch (cmd) {
208
209
  trantor gates verification gates: "must verify before shipping" claims that survive handoffs — [--all] [--json]
209
210
  trantor backfill card past GIT work onto the board (solo commits that were never carded) — [--since "14 days ago"] [--dry-run]
210
211
  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 <path>] [--adopt] [--json] — makes the dir, git main, CLAUDE.md from the brief, hooks, hub brief + first card (never spawns a session)
211
213
  trantor balances how much credit is left on each CONFIGURED provider (from your profile) — refill before you stall — [--json]
212
214
  trantor recost recompute sub-agent notional cost from on-disk transcripts + reseed the board (repair after upgrade) — [--dry-run]
213
215
  trantor handoff finish this session NOW: write a handoff, open a fresh session that takes over, and close this one (manual baton)
@@ -16,6 +16,11 @@ import { resolveProject, resolveHub, withEnvFiles, hostId } from "../lib/project
16
16
  import { loadOrCreate } from "../lib/identity.mjs";
17
17
  import { signedHeaders } from "../lib/signed-fetch.mjs";
18
18
  import { ensureEnrolled } from "../lib/enroll.mjs";
19
+ import { redactKeys } from "../lib/redact.mjs";
20
+ import {
21
+ AUTH_MARKER_RE, classifyFailure, looksLikeAuthDeath,
22
+ readPromptText, stripPromptEcho,
23
+ } from "../lib/classify-failure.mjs";
19
24
  import { capWake, capBcast, pickLessons, composePrompt } from "./crew-payload.mjs";
20
25
 
21
26
  const AGENT = process.argv[2];
@@ -127,7 +132,7 @@ if (!enrolment.ok && enrolment.reason !== "hub-unreachable") {
127
132
  }
128
133
  process.on("uncaughtException", (e) => { console.log(`\x1b[31m[runner] UNCAUGHT: ${e?.stack || e}\x1b[0m`); });
129
134
  process.on("unhandledRejection", (e) => { console.log(`\x1b[31m[runner] UNHANDLED REJECTION: ${e?.stack || e}\x1b[0m`); });
130
- const log = (s) => console.log(`\x1b[38;5;43m[runner]\x1b[0m ${s}`);
135
+ const log = (s) => console.log(`\x1b[38;5;43m[runner]\x1b[0m ${redactKeys(String(s))}`);
131
136
  const LOGDIR = join(homedir(), ".agent-bus", "logs");
132
137
  try { mkdirSync(LOGDIR, { recursive: true }); } catch {}
133
138
  let TURN = 0;
@@ -337,30 +342,19 @@ function loadPending() {
337
342
 
338
343
  // Auth-failure markers in TURN OUTPUT. opencode prints its auth error ("401 Unauthorized" /
339
344
  // "Invalid API key") and STILL exits 0, so a bare 0 from the CLI is not proof the turn ran
340
- // (card #5405). This regex gates the exit-0 path in runTurn; kept tighter than
341
- // classifyFailure's set (no bare /expired/) so a healthy transcript never trips it.
342
- const AUTH_MARKER_RE = /unauthor|401|403|forbidden|invalid[ _-]?api[ _-]?key|authentication? failed|token expired/i;
343
-
344
- function classifyFailure(exit, errText, emptyOutput = false) {
345
- // #5481: silence with a clean exit is a failure shape, not success — see lastEmptyOutput.
346
- if (emptyOutput) return "empty-output";
347
- const t = (errText || "").toLowerCase();
348
- if (exit === 127) return "missing-cli";
349
- // #5684: a provider BACKEND failure is not quota — it wants retry/swap, not a window wait.
350
- // The specimen (#5683): codex's "unexpected status 404 Not Found … /responses/compact" was
351
- // labelled "exhausted" and the operator was advised to wait out a window that did not exist.
352
- // 401/403/429 deliberately fall through to the auth/exhausted branches below.
353
- if (/unexpected status (404|408|410|5\d\d)|internal server error|bad gateway|service unavailable|gateway time.?out|econnrefused|connection refused|socket hang ?up|network is unreachable/.test(t)) return "backend-error";
354
- // "reached your … limit" / "usage limit" catch the subscription CLIs (Claude's "You've reached
355
- // your Fable 5 limit"), which say nothing about quota or credits and would otherwise read as a crash.
356
- if (/quota|insufficient|credit|balance|payment required|402|429|too many requests|rate.?limit|exceeded your|reached your [^.\n]*limit|usage limit|out of (credit|quota)/.test(t)) return "exhausted";
357
- if (/unauthor|401|invalid[ _-]?api[ _-]?key|forbidden|403|token expired|expired/.test(t)) return "auth";
358
- return "crashed";
345
+ // (card #5405). The rules live in lib/classify-failure.mjs (#5868) so they are testable against
346
+ // the real specimens; classify() wraps them with the one-line verdict the seat log carries, and
347
+ // runTurn judges only the CLI's OWN output (the prompt echo is replay, not speech — the rules
348
+ // line "…deleting failing tests is forbidden." once classified healthy codex turns as auth).
349
+ function classify(exit) {
350
+ const { reason, matched } = classifyFailure(exit, lastErrText, lastEmptyOutput);
351
+ log(`classified ${reason} because ${matched}`);
352
+ return reason;
359
353
  }
360
354
 
361
355
  async function reportFailure(exit, trigger, undelivered = 0) {
362
356
  consecFails++;
363
- const reason = classifyFailure(exit, lastErrText, lastEmptyOutput);
357
+ const reason = classify(exit);
364
358
  const down = consecFails >= 2;
365
359
  const status = down ? `down: ${reason} · ${consecFails} fails` : `errored: ${reason}`;
366
360
  await api("/register", { session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL }).catch(() => {});
@@ -377,9 +371,10 @@ async function reportFailure(exit, trigger, undelivered = 0) {
377
371
  // The count of messages this seat is HOLDING is the operator-actionable half of a failure: a
378
372
  // crashed pulse costs nothing, a crashed turn sitting on three escalations is someone waiting.
379
373
  const held = undelivered ? ` · holding ${undelivered} undelivered message${undelivered > 1 ? "s" : ""} (will retry)` : "";
380
- const text = down
374
+ // #5869: the broadcast quotes failure context; keys never ride the bus.
375
+ const text = redactKeys(down
381
376
  ? `🛑 ${SESSION} DOWN — ${consecFails} consecutive failures (${reason}, exit ${exit})${hint}${held}`
382
- : `⚠️ ${SESSION} turn FAILED (${trigger}, exit ${exit} · ${reason})${hint}${held}`;
377
+ : `⚠️ ${SESSION} turn FAILED (${trigger}, exit ${exit} · ${reason})${hint}${held}`);
383
378
  // Announce a CHANGE of state, never the continuation of one. The registered status above already
384
379
  // carries "down: exhausted · N fails" for anyone who looks, which is state and costs nobody a
385
380
  // turn; the broadcast is the event, and an unchanged state is not an event.
@@ -400,6 +395,23 @@ async function reportFailure(exit, trigger, undelivered = 0) {
400
395
  log(`\x1b[31mreported failure to bus: ${reason} (exit ${exit})\x1b[0m`);
401
396
  }
402
397
 
398
+ // ---- activity truth (#5965): the RUNNER is the source for this seat ----------------
399
+ // The app pulses a seat from its hub peer status. The runner is what actually knows when a
400
+ // turn starts and ends, so it reports the boundaries: `working · <trigger>` the moment a turn
401
+ // begins and `idle` the instant it lands clean. herdr's screen detection cannot see a
402
+ // runner-driven CLI mid-turn (it sets screen_detection_skipped for those panes), which is why
403
+ // seats used to read as idle while genuinely working — the desktop's herdr row is unreliable
404
+ // for runner seats, so it falls back to this hub status. Bounded 5s so a slow hub never delays
405
+ // the very turn it is reporting; one HTTP call per transition, never a poll.
406
+ async function registerStatus(status) {
407
+ const url = HUB + "/register";
408
+ const body = JSON.stringify({ session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL });
409
+ try {
410
+ const opts = { method: "POST", headers: { "content-type": "application/json", connection: "close" }, body };
411
+ await fetch(url, { ...opts, headers: { ...opts.headers, ...signedHeaders(identity, url, opts) }, signal: AbortSignal.timeout(5000) });
412
+ } catch {}
413
+ }
414
+
403
415
  // ---- telling the ASSIGNER, mechanically ------------------------------------
404
416
  // A seat used to finish its contract and say nothing. Completion lived only in the RULES prompt
405
417
  // ("report on the bus"), so a cheap model that did the work and ended its turn left the
@@ -410,6 +422,7 @@ async function reportFailure(exit, trigger, undelivered = 0) {
410
422
  // So: whoever sent the message that woke this seat gets told DIRECTLY what became of it. Direct
411
423
  // messages wake; that is the whole difference. Kept short, like every other bus line.
412
424
  async function notifyAssigners(pairs, text) {
425
+ text = redactKeys(text); // #5869: the "asked" excerpt quotes the wake message — keys stay off the bus
413
426
  const seen = new Set();
414
427
  for (const { from: f, id } of pairs) {
415
428
  // `hub:*` senders are the hub's own pseudo-ids (hub:duty, the overseer), not sessions: nothing
@@ -438,9 +451,13 @@ async function reportHealthy() {
438
451
  }
439
452
 
440
453
  let sid = "";
441
- function runTurn(prompt, isFirst, trigger = "kickoff") {
454
+ async function runTurn(prompt, isFirst, trigger = "kickoff") {
442
455
  TURN++; banner(trigger);
443
456
  const t0 = Date.now();
457
+ // #5965 — TURN START. The hub peer row is where the app reads activity from, and the runner is
458
+ // the only one who knows a turn is starting, so say so before the CLI spawn (awaited: the spawn
459
+ // below blocks the loop, an unawaited fetch would not leave the machine until the turn ended).
460
+ await registerStatus(`working · ${trigger}`);
444
461
  const pf = join(homedir(), ".agent-bus", `turn-${AGENT}-${PROJ}.txt`);
445
462
  appendFileSync(pf, "", { flag: "w" }); // truncate
446
463
  appendFileSync(pf, prompt);
@@ -470,7 +487,13 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
470
487
  // `crashed` and nobody knew to swap it. sid seats already fold stdout into the ERRF stream via
471
488
  // `tee /dev/stderr`; the rest now tee straight into ERRF. A real pipeline (not a process
472
489
  // substitution) so bash waits for tee to flush before we read the file back.
473
- const inner = cli.sid ? `${cmd} | tee /dev/stderr` : `${cmd} | tee -a ${ERRF}`;
490
+ // #5869: redaction rides IN the pipeline lib/redact.mjs is a tee replacement that echoes
491
+ // stdin verbatim to the live window and appends only REDACTED bytes to ERRF, so a CLI that
492
+ // echoes its environment never parks a provider key in a file every seat can read. The tee
493
+ // topology is load-bearing (#5481): stdout+stderr must still BOTH land in ERRF, and the sid
494
+ // path still folds stdout in via /dev/stderr → the --tee2 hop below.
495
+ const SCRUB = `node ${join(import.meta.dirname, "..", "lib", "redact.mjs")}`;
496
+ const inner = cli.sid ? `${cmd} | tee /dev/stderr` : `${cmd} | ${SCRUB} --tee ${ERRF}`;
474
497
  // #5684: runTurn is spawnSync, so the runner cannot watch its own turn — a DETACHED watchdog
475
498
  // does. Armed by a stamp file, disarmed when the turn ends (stamp removed below); a turn past
476
499
  // the window with no ERRF growth earns ONE direct stall report to the foreman, never a kill.
@@ -482,7 +505,7 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
482
505
  { detached: true, stdio: "ignore" });
483
506
  wd.unref();
484
507
  } catch {}
485
- const r = spawnSync("/bin/bash", ["-c", `set -o pipefail; { ${inner} ; } 2> >(tee -a ${ERRF} >&2)`], {
508
+ const r = spawnSync("/bin/bash", ["-c", `set -o pipefail; { ${inner} ; } 2> >(${SCRUB} --tee2 ${ERRF})`], {
486
509
  cwd: TURN_DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : "inherit",
487
510
  env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_PROJECT: PROJ,
488
511
  // A RUNNER-MANAGED SEAT MUST NEVER HAND ITSELF A BATON.
@@ -499,18 +522,31 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
499
522
  maxBuffer: 16 * 1024 * 1024,
500
523
  });
501
524
  try { unlinkSync(STAMPF); } catch {} // turn over — disarm the watchdog
502
- try { lastErrText = readFileSync(ERRF, "utf8").slice(-4000); } catch { lastErrText = ""; }
525
+ // #5869: scrub AT REST, synchronously, before anything reads the file back. The stderr hop is
526
+ // a process substitution bash does not wait for, so this pass also catches its tail — the
527
+ // auth classifier and the empty-output check below must judge REDACTED text and a settled file.
528
+ try { writeFileSync(ERRF, redactKeys(readFileSync(ERRF, "utf8"))); } catch {}
529
+ // #5868: classify only what the CLI itself said. The transcript replays the whole turn prompt
530
+ // (rules, lessons, the wake text) — and those lines once classified healthy codex turns as
531
+ // auth ("…is forbidden.") and exhausted ("retries burn quota"). Prompt lines are stripped
532
+ // before anything downstream looks at the text.
533
+ let ownOut = "";
534
+ try { ownOut = stripPromptEcho(readFileSync(ERRF, "utf8"), readPromptText(pf)); } catch { ownOut = ""; }
535
+ lastErrText = ownOut.slice(-4000);
503
536
  if (cli.sid && r.stdout) { const m = r.stdout.match(cli.sid); if (m) sid = m[1]; }
504
537
  const realExit = r.status;
505
538
  // A zero exit is NOT proof the turn ran: opencode prints "401 Unauthorized" / "Invalid API key"
506
539
  // and exits 0, so a bare 0 made the runner ack "✅ done", clear the pending queue and heartbeat
507
540
  // green through an auth outage (card #5405). Cross-check the turn output and treat an
508
- // exit-0-with-auth turn as FAILED. Telemetry keeps the REAL exit; the returned code is the
509
- // effective one every call site branches on (kickoff, pulse, deliverWake).
541
+ // exit-0-with-auth turn as FAILED but ONLY when the CLI's own output is short enough to be
542
+ // just the error (#5868): a long output is a real answer, and a warning inside it must not
543
+ // fail the turn. Telemetry keeps the REAL exit; the returned code is the effective one every
544
+ // call site branches on (kickoff, pulse, deliverWake).
510
545
  let effExit = realExit;
511
- if (realExit === 0 && AUTH_MARKER_RE.test(lastErrText)) {
546
+ if (realExit === 0 && looksLikeAuthDeath(ownOut)) {
512
547
  effExit = 1;
513
- log("\x1b[31mexit 0 but turn output shows an auth failure — treating as FAILED (auth)\x1b[0m");
548
+ const hit = AUTH_MARKER_RE.exec(ownOut)[0];
549
+ log(`\x1b[31mexit 0 but the turn output IS an auth failure — treating as FAILED (auth, "${hit}")\x1b[0m`);
514
550
  }
515
551
  // #5481: the Inception/Mercury trap — exit 0 with a NULL completion. ERRF is the TOTAL output
516
552
  // capture, not just stderr: every seat's stdout is tee'd into it (`| tee -a ERRF` for the
@@ -519,6 +555,8 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
519
555
  // real CLI prints something on success (drill C pins that), so silence is the trap, not a
520
556
  // quiet victory. (Integration note: this was nearly "fixed" into stdout-only detection that
521
557
  // never fired — the tee topology is the load-bearing fact; keep this comment with it.)
558
+ // The judgment now runs on the ECHO-STRIPPED text (#5868): a CLI that replays the prompt but
559
+ // does no work has still produced nothing of its own.
522
560
  if (realExit === 0 && effExit === 0 && !lastErrText.trim()) {
523
561
  effExit = 1;
524
562
  lastEmptyOutput = true;
@@ -527,6 +565,9 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
527
565
  telemetry({ ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, model: MODEL || "default", duration_ms: Date.now() - t0, exit: realExit, effExit, authFailed: effExit !== realExit, emptyOutput: lastEmptyOutput });
528
566
  log(`turn ended (exit ${realExit}${effExit !== realExit ? ` → effective ${effExit} (${lastEmptyOutput ? "empty-output" : "auth"})` : ""}, ${((Date.now() - t0) / 1000).toFixed(0)}s)`);
529
567
  if (realExit === 0 && effExit === 0) { cmuxStatus("idle", "#8a94a6", "robot"); herdrAgent("idle"); } // finished this turn, waiting for the next
568
+ // #5965 — TURN END. A clean exit means the seat is idle again; say so right away so the app stops
569
+ // pulsing it even before the next /poll heartbeat. Failure keeps reportFailure's down/errored.
570
+ if (realExit === 0 && effExit === 0) await registerStatus("idle");
530
571
  return effExit;
531
572
  }
532
573
 
@@ -592,7 +633,7 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
592
633
  let deliveryFails = 0; // consecutive failed attempts at the SAME pending batch
593
634
  if (pendingWake.length) log(`\x1b[33m${pendingWake.length} message(s) survived from a previous run — redelivering\x1b[0m`);
594
635
 
595
- const ec0 = runTurn(composedTurn({ base: KICKOFF, lessons: pickLessons(LESSONS_RAW, "") }), true, "kickoff");
636
+ const ec0 = await runTurn(composedTurn({ base: KICKOFF, lessons: pickLessons(LESSONS_RAW, "") }), true, "kickoff");
596
637
  if (ec0) await reportFailure(ec0, "kickoff", pendingWake.length); // a failed kickoff = the "fired up, died, nobody knew" case
597
638
  let lastTurnAt = Date.now();
598
639
  if (PULSE_MS) log(`pulse armed — mission re-read every ${Math.round(PULSE_MS / 1000)}s (${MISSION_FILE})`);
@@ -602,7 +643,7 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
602
643
  // pulse first: a due mission beat runs even on a silent bus. Measured from the END of the
603
644
  // last turn, so a long turn doesn't stack an immediate pulse on top of itself.
604
645
  if (PULSE_MS && Date.now() - lastTurnAt >= PULSE_MS) {
605
- const ecp = runTurn(composedTurn({ base: PULSE_PROMPT + "\n\n", rulesText: RULES, lessons: pickLessons(LESSONS_RAW, PULSE_PROMPT) }), false, "pulse");
646
+ const ecp = await runTurn(composedTurn({ base: PULSE_PROMPT + "\n\n", rulesText: RULES, lessons: pickLessons(LESSONS_RAW, PULSE_PROMPT) }), false, "pulse");
606
647
  if (ecp) await reportFailure(ecp, "pulse"); else await reportHealthy();
607
648
  lastTurnAt = Date.now();
608
649
  log("parked — waiting for the next message or pulse");
@@ -694,7 +735,7 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
694
735
  tailText: "\nAct on what's addressed to you, then end your turn.\n\n",
695
736
  rulesText: RULES, lessons,
696
737
  });
697
- const ec = runTurn(prompt, false, deliveryFails ? `${trigger} (redelivery)` : trigger);
738
+ const ec = await runTurn(prompt, false, deliveryFails ? `${trigger} (redelivery)` : trigger);
698
739
  const secs = Math.round((Date.now() - tStart) / 1000);
699
740
  if (ec) {
700
741
  deliveryFails++;
@@ -704,7 +745,7 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
704
745
  await reportFailure(ec, "message", pendingWake.length);
705
746
  // The room hears the broadcast above; the one who is actually blocked hears it directly.
706
747
  await notifyAssigners(assigners,
707
- `⚠️ your contract FAILED on ${SESSION} (exit ${ec}, ${classifyFailure(ec, lastErrText)}) · retrying in ${Math.round(wait / 1000)}s · asked: "${asked}"`);
748
+ `⚠️ your contract FAILED on ${SESSION} (exit ${ec}, ${classify(ec)}) · retrying in ${Math.round(wait / 1000)}s · asked: "${asked}"`);
708
749
  log(`\x1b[31m${pendingWake.length} message(s) still UNDELIVERED — next attempt in ${Math.round(wait / 1000)}s\x1b[0m`);
709
750
  } else {
710
751
  pendingWake = []; pendingBcast = []; deliveryFails = 0; retryAt = 0;
package/bin/crew.sh CHANGED
@@ -613,13 +613,16 @@ usage: trantor open [<project>]
613
613
  EOF
614
614
  }
615
615
  open_orchestrator() {
616
- local a
616
+ local a PROJ_ARG=""
617
617
  for a in "$@"; do case "$a" in
618
618
  --help|-h) usage_open; return 0 ;;
619
619
  --*) echo "trantor open: unknown flag '$a'"; usage_open; return 1 ;;
620
- *) PROJ="$a" ;;
620
+ *) PROJ="$a"; PROJ_ARG="$a" ;;
621
621
  esac; done
622
- DIR="$(_orch_resolve_dir "$DIR" "$PROJ")" || exit 1
622
+ # only an EXPLICIT name is resolved to its checkout; a project declared by RELAY_PROJECT from
623
+ # inside a differently named dir (the test harness, RELAY_PROJECT=<name> sessions) means "this
624
+ # cwd IS the project" — refusing it made `trantor open` impossible for exactly those sessions
625
+ DIR="$(_orch_resolve_dir "$DIR" "${PROJ_ARG:-}")" || exit 1
623
626
  command -v herdr >/dev/null 2>&1 || { echo "trantor open needs herdr (the pane host) — install: curl -fsSL https://herdr.dev/install.sh | sh"; exit 1; }
624
627
  local wsid="" orch="" live_ids="" live_names="" pair="" line fresh=0
625
628
  local sid; sid="$(_orch_sid "$PROJ")" || { echo "trantor open: could not mint a session id (uuidgen missing?)" >&2; exit 1; }
package/bin/new.mjs ADDED
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+ // trantor new — project genesis, the CLI half (#5862). One command stands a project up:
3
+ //
4
+ // trantor new <name> [--from <git-url>] [--brief <file>] [--dir <path>] [--adopt] [--json]
5
+ //
6
+ // It makes the directory under the dev root (TRANTOR_DEV_ROOT or ~/development), starts git on
7
+ // main (or clones --from, or adopts an existing folder with --adopt), seeds CLAUDE.md from the
8
+ // brief (verbatim brief + the trantor conventions block), installs the same auto-card hook as
9
+ // `trantor init-hooks`, posts the brief as the hub project brief (POST /project — the same call
10
+ // relay_project_brief makes), and opens the first card "genesis: <name>" on the new board.
11
+ //
12
+ // It NEVER spawns a session: the wake is genesis-2, the app half. A hub that is down downgrades
13
+ // the genesis to a warning (card: null) — the directory is real either way, and a dead hub must
14
+ // not make a made project look unmade.
15
+ import { spawnSync } from "node:child_process";
16
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, appendFileSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { dirname, isAbsolute, join, resolve } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { ensureEnrolled, loadIdentity, signedPost } from "../hooks/lib/api.mjs";
21
+ import { setAutonomy } from "../lib/autonomy.mjs";
22
+ import { resolveHub, setProjectHub } from "../lib/project.mjs";
23
+
24
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
25
+
26
+ // The trantor conventions block — what every trantor-wired project's CLAUDE.md carries so the
27
+ // first session knows the board, the crew, and the gates exist. Kept SHORT and factual; the
28
+ // brief above it is the project's own voice.
29
+ const CONVENTIONS = [
30
+ "",
31
+ "## Trantor conventions",
32
+ "",
33
+ "This project is wired into Trantor (the board, the bus, the crew):",
34
+ "",
35
+ "- **Board** — work lives on the board, not in anyone's head. `trantor catchup` answers",
36
+ " \"where are we?\"; the desktop app (`trantor app install`) shows the live board.",
37
+ "- **Crew** — `trantor up codex kimi glm` fires seats into their own worktrees; contracts go",
38
+ " over the bus (`relay_send`), and every seat reports what became of its card. See the",
39
+ " `/trantor:crew` skill for the full doctrine before firing anything.",
40
+ "- **Gates** — a card reaches done only with real evidence: the test command, the counts,",
41
+ " and the observed behavior. \"It should work\" is not a state a card can be in.",
42
+ "- **Commits card themselves** — the post-commit hook (trantor auto-card) backfills the board",
43
+ " from git; keep commit messages in the imperative and reference card ids when one exists.",
44
+ "",
45
+ ].join("\n");
46
+
47
+ const die = (msg) => { console.error(`trantor new: ${msg}`); process.exit(1); };
48
+
49
+ // ── args ────────────────────────────────────────────────────────────────────────────────────────
50
+ const args = process.argv.slice(2);
51
+ const flag = (n) => { const i = args.indexOf("--" + n); return i >= 0 ? args[i + 1] : null; };
52
+ const has = (n) => args.includes("--" + n);
53
+ const json = has("json");
54
+ const name = args.find(a => !a.startsWith("--"));
55
+ if (!name) die("usage: trantor new <name> [--from <git-url>] [--brief <file>] [--dir <path>] [--adopt] [--json]");
56
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) die(`invalid project name "${name}" — letters, digits, dot, dash, underscore`);
57
+ const from = flag("from");
58
+ const briefFile = flag("brief");
59
+ const adopt = has("adopt");
60
+ // The brief is read BEFORE anything is created: a refused genesis must leave no directory behind.
61
+ let brief = "";
62
+ if (briefFile) {
63
+ if (!existsSync(briefFile)) die(`brief file not found: ${briefFile}`);
64
+ brief = readFileSync(briefFile, "utf8").trimEnd();
65
+ }
66
+
67
+ // ── the directory ───────────────────────────────────────────────────────────────────────────────
68
+ const dirArg = flag("dir");
69
+ const devRoot = dirArg
70
+ ? resolve(dirArg)
71
+ : (process.env.TRANTOR_DEV_ROOT ? resolve(process.env.TRANTOR_DEV_ROOT) : join(homedir(), "development"));
72
+ if (dirArg && !isAbsolute(dirArg)) { /* relative --dir is allowed; resolved above */ }
73
+ const dir = isAbsolute(name) ? name : join(devRoot, name);
74
+
75
+ const existed = existsSync(dir);
76
+ const occupied = existed && readdirSync(dir).length > 0;
77
+ if (occupied && !adopt) die(`"${dir}" already exists and is not empty — pass --adopt to adopt it`);
78
+ if (adopt && !existed) die(`--adopt: "${dir}" does not exist — nothing to adopt`);
79
+ if (!existed) mkdirSync(dir, { recursive: true });
80
+
81
+ // ── git ─────────────────────────────────────────────────────────────────────────────────────────
82
+ const git = (gitArgs) => spawnSync("git", gitArgs, { cwd: dir, encoding: "utf8" });
83
+ const gitOk = (r, what) => { if (r.status !== 0) die(`${what} failed: ${(r.stderr || r.stdout || "").trim()}`); };
84
+
85
+ let branch;
86
+ if (from) {
87
+ gitOk(git(["clone", from, "."]), "git clone");
88
+ branch = git(["branch", "--show-current"]).stdout.trim() || "main";
89
+ } else {
90
+ if (!existsSync(join(dir, ".git"))) gitOk(git(["init", "-b", "main"]), "git init");
91
+ branch = git(["branch", "--show-current"]).stdout.trim() || "main";
92
+ }
93
+
94
+ // ── CLAUDE.md — verbatim brief + the conventions block ──────────────────────────────────────────
95
+ const claude = join(dir, "CLAUDE.md");
96
+ if (existsSync(claude)) {
97
+ const current = readFileSync(claude, "utf8");
98
+ if (!current.includes("## Trantor conventions")) appendFileSync(claude, `\n${CONVENTIONS}\n`);
99
+ } else {
100
+ const head = brief ? `${brief}\n` : `# ${name}\n\n(Genesis — no brief was given. Add this project's what/why/goal here.)\n`;
101
+ writeFileSync(claude, `${head}${CONVENTIONS}`);
102
+ }
103
+
104
+ // ── hooks — the SAME install trantor init-hooks performs, in the new repo ───────────────────────
105
+ const hook = spawnSync(process.execPath, [join(ROOT, "bin", "init-hooks.mjs")], { cwd: dir, encoding: "utf8" });
106
+ if (hook.status !== 0) die(`hook install failed: ${(hook.stderr || "").trim()}`);
107
+
108
+ // A new project is a new trust boundary. Pin its harness dial even when the machine-wide default
109
+ // is bypass, so opening it can never inherit another project's permission choice.
110
+ setAutonomy(name, { harness: "prompt" });
111
+
112
+ // ── the hub: pin + brief + first card, signed like every other client ──────────────────────────
113
+ const hub = resolveHub(name);
114
+ // Pin the new project to the hub it posts to (#5862 residual): without the pin, the first
115
+ // session in the dir falls back to the global default and wears the "not pinned to a hub"
116
+ // warning even though genesis chose this hub deliberately. Same persistence as `trantor hub set`.
117
+ setProjectHub(name, hub);
118
+ const session = process.env.RELAY_SESSION || `genesis:${name}`;
119
+ const identity = loadIdentity(session);
120
+ let card = null;
121
+ let hubError = null;
122
+ try {
123
+ await ensureEnrolled(session, identity, name);
124
+ const briefForHub = (brief || `Genesis of ${name} — created by trantor new.`).slice(0, 600);
125
+ const r1 = await signedPost("/project", { project: name, brief: briefForHub, by: session }, { session, project: name, timeoutMs: 8000 });
126
+ if (!r1.ok) throw new Error(`hub ${r1.status} on /project`);
127
+ const r2 = await signedPost("/task", {
128
+ project: name,
129
+ title: `genesis: ${name}`,
130
+ status: "todo",
131
+ by: session,
132
+ note: "project genesis — created by trantor new",
133
+ }, { session, project: name, timeoutMs: 8000 });
134
+ if (!r2.ok) throw new Error(`hub ${r2.status} on /task`);
135
+ card = r2.json?.task?.id ?? null;
136
+ } catch (e) {
137
+ hubError = e instanceof Error ? e.message : String(e);
138
+ console.error(`trantor new: hub unreachable or refusing (${hubError}) — the project exists locally; the brief and first card were NOT posted.`);
139
+ }
140
+
141
+ // ── report ──────────────────────────────────────────────────────────────────────────────────────
142
+ if (json) {
143
+ console.log(JSON.stringify({ name, dir, branch, hub, card }));
144
+ } else {
145
+ console.log(`✓ ${dir} (${branch}${from ? ", cloned" : adopt ? ", adopted" : ""})`);
146
+ console.log(`✓ CLAUDE.md seeded${brief ? " from the brief" : " (no brief — add the project's what/why/goal)"}`);
147
+ console.log(`✓ auto-card hook installed (trantor init-hooks)`);
148
+ if (card !== null) console.log(`✓ hub ${hub}: brief posted, card #${card} ("genesis: ${name}")`);
149
+ else if (hubError) console.log(`! hub ${hub}: brief/card not posted (${hubError})`);
150
+ console.log(`\nNext: cd ${dir} && claude — or fire the crew with \`trantor up\`. No session was spawned.`);
151
+ }
package/bin/profile.mjs CHANGED
@@ -43,7 +43,12 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
43
43
  if (cmd === "set") {
44
44
  for (const a of args) {
45
45
  const [prov, plan] = a.split("=");
46
- if (!prov || !plan) { console.error(`bad arg '${a}' use provider=plan`); process.exit(1); }
46
+ // A flag in the provider position (`set --help=api`) is a usage mistake, not a provider
47
+ // die before the write, or profile.json grows a '--help' entry (#5998). 'help' likewise.
48
+ if (!prov || !plan || prov.startsWith("--") || prov === "help") {
49
+ console.error(`bad arg '${a}' — use provider=plan`);
50
+ process.exit(1);
51
+ }
47
52
  prof.providers[prov.toLowerCase()] = { plan: plan.toLowerCase(), tier: TIER(plan) };
48
53
  }
49
54
  prof.updated = new Date().toISOString().slice(0, 10);
package/bin/provider.mjs CHANGED
@@ -70,7 +70,14 @@ function listSeats() {
70
70
  }
71
71
 
72
72
  function addProvider(name, opts) {
73
- if (!name) { console.error("usage: trantor provider add <name> [--key sk-…] [--plan api] [--label <bus-name>] [--base-url <url> [--models m1,m2]]"); process.exit(1); }
73
+ // A flag left in the name position (`provider add --help`) is a usage question, not a provider
74
+ // name (#5998): the old path minted a '--help' provider into profile.json and a __HELP_API_KEY
75
+ // line into .env, then announced "Seat ready." The literal 'help' is guarded too — it is never
76
+ // a provider name. Both die HERE, before .env, profile.json or opencode.json are touched.
77
+ if (!name || name.startsWith("--") || name === "help") {
78
+ console.error("usage: trantor provider add <name> [--key sk-…] [--plan api] [--label <bus-name>] [--base-url <url> [--models m1,m2]]");
79
+ process.exit(1);
80
+ }
74
81
  const provider = name.toLowerCase();
75
82
  const label = (opts.label || provider).toLowerCase().replace(/[^a-z0-9-]/g, "-");
76
83
  const plan = (opts.plan || "api").toLowerCase();
@@ -117,7 +124,11 @@ function addProvider(name, opts) {
117
124
  }
118
125
 
119
126
  function removeProvider(name) {
120
- if (!name) { console.error("usage: trantor provider remove <name>"); process.exit(1); }
127
+ // Same guard as add (#5998): a flag-like name is a usage question, not a provider.
128
+ if (!name || name.startsWith("--") || name === "help") {
129
+ console.error("usage: trantor provider remove <name>");
130
+ process.exit(1);
131
+ }
121
132
  const FILE = join(H, ".agent-bus", "profile.json");
122
133
  const prof = read(FILE, { providers: {} });
123
134
  if (prof.providers && prof.providers[name.toLowerCase()]) {
@@ -0,0 +1,66 @@
1
+ // Failure classification for crew seats (#5868) — split out of crew-runner.mjs so the rules are
2
+ // unit-testable against the REAL specimens that misfired.
3
+ //
4
+ // EVIDENCE (err-codex-trantor.txt + logs/codex-trantor.jsonl, 2026-09-02): the codex seat sat
5
+ // "DOWN — 44 consecutive failures (exhausted)" while every telemetry row read exit:0 and the
6
+ // transcript showed real work ("tokens used 4,387", Stop hooks, a delivered answer). Both labels
7
+ // were echoes of the seat's own PROMPT: the rules line "…deleting failing tests is forbidden."
8
+ // matched AUTH_MARKER_RE's bare "forbidden", and the codex lesson "retries burn quota" matched
9
+ // the exhausted rule. Three fixes, one per failure mode:
10
+ // · stripPromptEcho — a prompt line reappearing in the err stream is the CLI REPLAYING what it
11
+ // was told, not the CLI speaking; classification sees only the CLI's own output.
12
+ // · looksLikeAuthDeath — the #5405 exit-0 escalation fires only on a SHORT error-only output
13
+ // (the opencode "401 Unauthorized" specimen is a couple of lines); a long output is a real
14
+ // answer, and a warning inside it must not fail the turn.
15
+ // · classifyFailure — "exhausted" demands an explicit quota/rate-limit message; the broad bare
16
+ // words ("credit", "balance", bare "insufficient") labelled ordinary prose on a dead turn and
17
+ // sent the operator to wait out a window that did not exist.
18
+ import { readFileSync } from "node:fs";
19
+
20
+ export const AUTH_MARKER_RE = /unauthor|401|403|forbidden|invalid[ _-]?api[ _-]?key|authentication? failed|token expired/i;
21
+
22
+ /** Prompt lines (≥40 chars — a short line carries no signature) echoed back verbatim by the CLI
23
+ * are replay, not speech. Returns the err stream with those lines removed. */
24
+ export function stripPromptEcho(errText, promptText) {
25
+ const text = String(errText || "");
26
+ if (!promptText) return text;
27
+ const echo = new Set(String(promptText).split("\n").filter(l => l.length >= 40));
28
+ return text.split("\n").filter(l => !(l.length >= 40 && echo.has(l))).join("\n");
29
+ }
30
+
31
+ /** A real answer is long; an auth death is a couple of lines. The opencode specimen (#5405)
32
+ * printed its whole failure in under a hundred characters and produced nothing else. */
33
+ export const OWN_OUTPUT_ANSWER_MIN = 400;
34
+
35
+ /** The #5405 rule, refined by #5868: exit 0 + an auth-shaped marker means FAILED only when the
36
+ * CLI's own output is short enough to be JUST the error. */
37
+ export function looksLikeAuthDeath(ownText) {
38
+ return String(ownText || "").length < OWN_OUTPUT_ANSWER_MIN && AUTH_MARKER_RE.test(ownText);
39
+ }
40
+
41
+ /** Classify one failed turn. Returns { reason, matched } — matched is the evidence excerpt the
42
+ * runner logs, so the next misclassification is diagnosable from the seat log alone. */
43
+ export function classifyFailure(exit, errText, emptyOutput = false) {
44
+ // #5481: silence with a clean exit is a failure shape, not success — see lastEmptyOutput.
45
+ if (emptyOutput) return { reason: "empty-output", matched: "exit 0 with no output on either stream" };
46
+ const t = String(errText || "").toLowerCase();
47
+ if (exit === 127) return { reason: "missing-cli", matched: "exit 127 — command not found" };
48
+ // #5684: a provider BACKEND failure is not quota — it wants retry/swap, not a window wait.
49
+ // The specimen (#5683): codex's "unexpected status 404 Not Found … /responses/compact" was
50
+ // labelled "exhausted" and the operator was advised to wait out a window that did not exist.
51
+ // 401/403/429 deliberately fall through to the auth/exhausted branches below.
52
+ let m = t.match(/unexpected status (?:404|408|410|5\d\d)|internal server error|bad gateway|service unavailable|gateway time.?out|econnrefused|connection refused|socket hang ?up|network is unreachable/);
53
+ if (m) return { reason: "backend-error", matched: m[0] };
54
+ // "reached your … limit" / "usage limit" catch the subscription CLIs (Claude's "You've reached
55
+ // your Fable 5 limit"), which say nothing about quota or credits and would otherwise read as a crash.
56
+ m = t.match(/quota|payment required|402|429|too many requests|rate.?limit|usage limit|exceeded your|reached your [^.\n]*limit|insufficient (?:credits?|funds)|out of (?:credits?|quota)/);
57
+ if (m) return { reason: "exhausted", matched: m[0] };
58
+ m = t.match(/unauthor|401|invalid[ _-]?api[ _-]?key|forbidden|403|token expired|expired/);
59
+ if (m) return { reason: "auth", matched: m[0] };
60
+ return { reason: "crashed", matched: `exit ${exit} with no known failure pattern` };
61
+ }
62
+
63
+ /** Read the prompt file if it exists (the turn prompt the CLI may echo into its transcript). */
64
+ export function readPromptText(pf) {
65
+ try { return readFileSync(pf, "utf8"); } catch { return ""; }
66
+ }
package/lib/redact.mjs ADDED
@@ -0,0 +1,62 @@
1
+ // #5869 — key-material redaction for everything a seat runner writes at rest.
2
+ //
3
+ // A CLI that echoes its environment or dumps a config puts live provider keys into the seat's
4
+ // err log (err-<agent>-<project>.txt), and any seat on the machine can read ~/.agent-bus. So the
5
+ // runner scrubs known key shapes to `<redacted:NAME>` before the bytes land:
6
+ //
7
+ // sk-… / sk-sp-… / sk-ws-… → <redacted:SK> (every OpenAI-style prefix is sk-)
8
+ // AIza… → <redacted:AIZA> (Google)
9
+ // xai-… → <redacted:XAI> (xAI)
10
+ // ghp_… → <redacted:GHP> (GitHub PAT)
11
+ // <VAR>_KEY=… / <VAR>_TOKEN=… with a 32+-char hex/base64 value
12
+ // → <VAR>=<redacted:VAR> (the variable NAME is not secret)
13
+ // Authorization: Bearer … → Authorization: Bearer <redacted:BEARER>
14
+ //
15
+ // Ordinary lines are byte-identical: every rule anchors on a key prefix or a KEY=/TOKEN=/Bearer
16
+ // position, never on "looks long". The function is idempotent (already-redacted text passes
17
+ // through unchanged), so callers can scrub in the write path AND again on read-back.
18
+ const RULES = [
19
+ // VAR=value first, so the surviving name is the env var and short values after KEY=/TOKEN=
20
+ // still fall through to the bare-prefix rules below.
21
+ { re: /\b([A-Za-z0-9_.-]*(?:KEY|TOKEN))=(["']?)[A-Za-z0-9+/_=-]{32,}\2/g, sub: (m, name, q) => `${name}=${q}<redacted:${name}>` },
22
+ { re: /\bsk-[A-Za-z0-9][A-Za-z0-9_-]{7,}/g, sub: () => "<redacted:SK>" },
23
+ { re: /\bAIza[0-9A-Za-z_-]{30,}/g, sub: () => "<redacted:AIZA>" },
24
+ { re: /\bxai-[A-Za-z0-9][A-Za-z0-9_-]{9,}/g, sub: () => "<redacted:XAI>" },
25
+ { re: /\bghp_[A-Za-z0-9]{20,}/g, sub: () => "<redacted:GHP>" },
26
+ { re: /(Authorization:\s*Bearer\s+)[A-Za-z0-9._+/=-]{16,}/gi, sub: (m, p) => `${p}<redacted:BEARER>` },
27
+ ];
28
+
29
+ export function redactKeys(text) {
30
+ if (text == null) return text;
31
+ let out = String(text);
32
+ for (const { re, sub } of RULES) out = out.replace(re, sub);
33
+ return out;
34
+ }
35
+
36
+ // The runner's tee replacement. Modes:
37
+ // --tee <file> stdin → STDOUT verbatim (the live window), redacted → <file> (append)
38
+ // --tee2 <file> stdin → STDERR verbatim (the live window), redacted → <file> (append)
39
+ // The redacted append is LINE-BUFFERED: chunks can split a match mid-token (a 100-char bearer
40
+ // JWT straddling a 64KB read boundary would otherwise leak its tail), but keys never span LINES.
41
+ // Complete lines go out redacted; a partial trailing line waits for its newline. Every byte is
42
+ // appended exactly once, in order, so the file stays verbatim apart from redactions.
43
+ if (process.argv[1] && process.argv[1].endsWith("redact.mjs") && (process.argv[2] === "--tee" || process.argv[2] === "--tee2")) {
44
+ const { appendFileSync } = await import("node:fs");
45
+ const target = process.argv[3];
46
+ const passthrough = process.argv[2] === "--tee"
47
+ ? (c) => process.stdout.write(c)
48
+ : (c) => process.stderr.write(c);
49
+ let carry = "";
50
+ process.stdin.on("data", (chunk) => {
51
+ passthrough(chunk);
52
+ const lines = (carry + chunk.toString("utf8")).split("\n");
53
+ carry = lines.pop(); // the partial trailing line — or "" when the chunk ended on a newline
54
+ if (lines.length) {
55
+ try { appendFileSync(target, redactKeys(lines.join("\n")) + "\n"); } catch {}
56
+ }
57
+ });
58
+ process.stdin.on("end", () => {
59
+ if (carry) { try { appendFileSync(target, redactKeys(carry)); } catch {} }
60
+ });
61
+ process.stdin.resume();
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.28",
3
+ "version": "0.18.30",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
@@ -11,7 +11,7 @@
11
11
  "zod": "^4.4.3"
12
12
  },
13
13
  "scripts": {
14
- "test": "node bin/slop-gate.mjs --surface desktop/src && node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-redelivery.mjs && node test-crew-completion.mjs && node test-contracts.mjs && node test-autonomy.mjs && node test-integrate.mjs && node test-handoff.mjs && node test-handoff-summarizer.mjs && node test-baton-turn-boundary.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-baton-latest.mjs && node test-balances.mjs && node test-usage-live.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-cardlog.mjs && node test-checklist.mjs && node test-relay-note.mjs && node test-reaper.mjs && node test-events.mjs && node test-proposals.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-message-re.mjs && node test-claims.mjs && node test-adopt.mjs && node test-summarize.mjs && node test-identity-core.mjs && node test-identity.mjs && node test-identity-instances.mjs && node test-duty.mjs && node test-duty-dark.mjs && node test-duty-seat.mjs && node test-discovery.mjs && node test-doctor.mjs && node test-crew-env.mjs && node test-desktop-transport.mjs && node test-crew-worktree.mjs && bash test-crew-herdr.sh && npm --prefix desktop run test --silent && node test-splitbrain.mjs && node test-overseer.mjs && node test-overseer-lib.mjs && node test-overseer-warn.mjs && node test-provider-keys.mjs && node test-inbox-delivery.mjs && node test-identity-drift.mjs && node test-inbox-staleness.mjs && node test-seats.mjs && node test-seat-identity.mjs && node test-hub-routing.mjs && node test-hook-routing.mjs && node test-relay-wait.mjs && node test-dsh-seat.mjs && node test-kimi-bridge.mjs && node test-kimi-events.mjs && python3 engine/test-routing.py && node test-reconcile.mjs && node test-resources.mjs && node test-patrol.mjs && node test-bridge.mjs && bash test-crew.sh"
14
+ "test": "node bin/slop-gate.mjs --surface desktop/src && node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-redelivery.mjs && node test-crew-completion.mjs && node test-contracts.mjs && node test-autonomy.mjs && node test-integrate.mjs && node test-handoff.mjs && node test-handoff-summarizer.mjs && node test-baton-turn-boundary.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-baton-latest.mjs && node test-balances.mjs && node test-usage-live.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-cardlog.mjs && node test-checklist.mjs && node test-relay-note.mjs && node test-reaper.mjs && node test-events.mjs && node test-proposals.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-message-re.mjs && node test-claims.mjs && node test-adopt.mjs && node test-summarize.mjs && node test-identity-core.mjs && node test-identity.mjs && node test-identity-instances.mjs && node test-duty.mjs && node test-duty-dark.mjs && node test-duty-seat.mjs && node test-discovery.mjs && node test-doctor.mjs && node test-crew-env.mjs && node test-desktop-transport.mjs && node test-crew-worktree.mjs && node test-crew-redact.mjs && node test-crew-classify.mjs && bash test-crew-herdr.sh && npm --prefix desktop run test --silent && node test-splitbrain.mjs && node test-overseer.mjs && node test-overseer-lib.mjs && node test-overseer-warn.mjs && node test-provider-keys.mjs && node test-provider-cli.mjs && node test-inbox-delivery.mjs && node test-identity-drift.mjs && node test-inbox-staleness.mjs && node test-seats.mjs && node test-seat-identity.mjs && node test-hub-routing.mjs && node test-hook-routing.mjs && node test-relay-wait.mjs && node test-dsh-seat.mjs && node test-kimi-bridge.mjs && node test-kimi-events.mjs && python3 engine/test-routing.py && node test-new.mjs && node test-reconcile.mjs && node test-resources.mjs && node test-patrol.mjs && node test-bridge.mjs && bash test-crew.sh"
15
15
  },
16
16
  "description": "The hub-world for AI agent crews — orchestrate Claude Code, Codex, GLM, Kimi, DeepSeek & any OpenRouter model as live crews with a plan-aware Advisor, a Kanban/flow command center, a testing gate, and an economics brain (Scrooge).",
17
17
  "files": [
@@ -32,6 +32,9 @@ PRD.md + TDD.md. The TDD MUST define one file-set per agent (no merge conflicts)
32
32
  explicit EVENT/INTERFACE CONTRACT — cross-agent bugs come from contract drift.
33
33
 
34
34
  ## Phase 1 — board setup
35
+ 0. No project yet? `trantor new <name> --brief <file>` stands one up (dir, git main, CLAUDE.md
36
+ from the brief, hooks, hub brief + first card) — it never spawns a session; firing the crew
37
+ is this phase's job.
35
38
  1. `relay_project_brief("<what + why + goal>")`
36
39
  2. One card per package: `relay_task_add(title, assignee, difficulty, model)` — set `model`
37
40
  to the advisor-routed model (or the CLI's default name); difficulty + model show as badges