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 +7 -1
- package/bin/cli.mjs +2 -0
- package/bin/crew-runner.mjs +76 -35
- package/bin/crew.sh +6 -3
- package/bin/new.mjs +151 -0
- package/bin/profile.mjs +6 -1
- package/bin/provider.mjs +13 -2
- package/lib/classify-failure.mjs +66 -0
- package/lib/redact.mjs +62 -0
- package/package.json +2 -2
- package/skills/crew/SKILL.md +3 -0
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)
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -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).
|
|
341
|
-
//
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
function
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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> >(
|
|
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
|
-
|
|
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
|
|
509
|
-
//
|
|
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 &&
|
|
546
|
+
if (realExit === 0 && looksLikeAuthDeath(ownOut)) {
|
|
512
547
|
effExit = 1;
|
|
513
|
-
|
|
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}, ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": [
|
package/skills/crew/SKILL.md
CHANGED
|
@@ -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
|