trantor 0.18.33 → 0.18.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +9 -3
- package/bin/cli.mjs +3 -1
- package/bin/crew-runner.mjs +135 -16
- package/bin/crew.sh +6 -2
- package/bin/genesis-kickoff.mjs +92 -0
- package/bin/new.mjs +18 -7
- package/bin/seat-why.mjs +2 -1
- package/hub.mjs +5 -1
- package/lib/seat-why.mjs +27 -1
- package/lib/turn-policy.mjs +114 -0
- package/mcp.mjs +50 -6
- package/package.json +2 -2
- package/skills/prd-review/SKILL.md +159 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.35",
|
|
4
4
|
"description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
|
|
5
5
|
"mcpServers": {
|
|
6
6
|
"relay": {
|
package/README.md
CHANGED
|
@@ -399,9 +399,15 @@ trantor setup | doctor | connect | profile | provider | models
|
|
|
399
399
|
|
|
400
400
|
- **`trantor new <name>`** — project genesis in one command: makes the dir under your dev root
|
|
401
401
|
(`TRANTOR_DEV_ROOT` or `~/development`), git on main (or `--from <git-url>`, or `--adopt` an
|
|
402
|
-
existing folder),
|
|
403
|
-
|
|
404
|
-
|
|
402
|
+
existing folder), installs the auto-card hook, posts the brief to the hub, and opens the first
|
|
403
|
+
card "genesis: <name>". `--json` for machines. It never spawns a session — firing the crew
|
|
404
|
+
stays your call. Two paths in: **blank** (no brief) wakes the orchestrator plainly and you work
|
|
405
|
+
with it iteratively; **from a brief** (`--brief <file>`, or a PRD dropped on the app's Genesis
|
|
406
|
+
sheet) stores it in `docs/PRD.md` with a small CLAUDE.md pointer, and the wake runs
|
|
407
|
+
**`/trantor:prd-review`**: every live crew seat plus two Scrooge readers review the PRD
|
|
408
|
+
independently against one rubric, the orchestrator synthesizes the consensus and asks you,
|
|
409
|
+
the TDD gets the same review, and only then do the build cards open. A parked project with
|
|
410
|
+
`docs/PRD.md` and no build cards takes the same path on its next Wake.
|
|
405
411
|
|
|
406
412
|
- **`trantor provider`** — `list` every crew seat (built-in + brought) with availability + tier ·
|
|
407
413
|
`add <name> --key … [--plan api] [--base-url <url> --models a,b]` to bring any provider (custom
|
package/bin/cli.mjs
CHANGED
|
@@ -70,6 +70,7 @@ switch (cmd) {
|
|
|
70
70
|
case "reconcile": run("bin/reconcile.mjs"); break;
|
|
71
71
|
case "init-hooks": run("bin/init-hooks.mjs"); break;
|
|
72
72
|
case "new": run("bin/new.mjs"); break;
|
|
73
|
+
case "genesis-kickoff": run("bin/genesis-kickoff.mjs"); break;
|
|
73
74
|
case "balances": case "balance": case "credits": run("bin/balances.mjs"); break;
|
|
74
75
|
case "recost": run("bin/recost.mjs"); break;
|
|
75
76
|
case "handoff": run("bin/baton.mjs"); break;
|
|
@@ -209,7 +210,8 @@ switch (cmd) {
|
|
|
209
210
|
trantor gates verification gates: "must verify before shipping" claims that survive handoffs — [--all] [--json]
|
|
210
211
|
trantor backfill card past GIT work onto the board (solo commits that were never carded) — [--since "14 days ago"] [--dry-run]
|
|
211
212
|
trantor init-hooks install a git post-commit hook so EVERY commit auto-cards on the board (reliable solo-work backstop) — [--uninstall]
|
|
212
|
-
trantor new project genesis: new <name> [--from <git-url>] [--brief <file>] [--dir <parent>] [--adopt] [--json] — creates <parent>/<name>, git main,
|
|
213
|
+
trantor new project genesis: new <name> [--from <git-url>] [--brief <file>] [--dir <parent>] [--adopt] [--json] — creates <parent>/<name>, git main, docs/PRD.md + small CLAUDE.md, hooks, hub brief + first card (never spawns a session)
|
|
214
|
+
trantor genesis-kickoff internal wake prompt selector from the checkout + signed project board
|
|
213
215
|
trantor balances how much credit is left on each CONFIGURED provider (from your profile) — refill before you stall — [--json]
|
|
214
216
|
trantor recost recompute sub-agent notional cost from on-disk transcripts + reseed the board (repair after upgrade) — [--dry-run]
|
|
215
217
|
trantor handoff finish this session NOW: write a handoff, open a fresh session that takes over, and close this one (manual baton)
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -23,6 +23,9 @@ import {
|
|
|
23
23
|
readPromptText, stripPromptEcho,
|
|
24
24
|
} from "../lib/classify-failure.mjs";
|
|
25
25
|
import { capWake, capBcast, pickLessons, composePrompt } from "./crew-payload.mjs";
|
|
26
|
+
import {
|
|
27
|
+
cardRef, carriesWork, parseTurnTokens, parseResetAt, reasonWithBalances, PARKING_REASONS,
|
|
28
|
+
} from "../lib/turn-policy.mjs";
|
|
26
29
|
|
|
27
30
|
const AGENT = process.argv[2];
|
|
28
31
|
const DIR = process.argv[3] || process.cwd();
|
|
@@ -272,7 +275,7 @@ if (!CLI[AGENT]) log(`'${AGENT}' is not a built-in seat — running it as an ope
|
|
|
272
275
|
|
|
273
276
|
// RUNNER_RULES / RUNNER_KICKOFF env overrides: the runner is also the substrate for non-crew
|
|
274
277
|
// always-on seats (the fleet DUTY agent, bin/duty.mjs) whose doctrine is not "work your card".
|
|
275
|
-
const RULES = process.env.RUNNER_RULES || `Rules: you are ${SESSION} on the trantor crew. Before starting a card,
|
|
278
|
+
const RULES = process.env.RUNNER_RULES || `Rules: you are ${SESSION} on the trantor crew. Before starting a card, read YOUR card: relay_board with card:<id> (the card, its deps, its notes, and the last five done cards whose title shares a word); never the whole board. Work your assigned file(s), report on the bus (relay_send, <280 chars), move your Kanban card as you go with a NOTE saying what you did (doing -> testing -> done; in 'testing' run YOUR OWN test file — never the full npm test, suites collide across seats — plus \`node bin/slop-gate.mjs\` when the repo has one: it lints ONLY your changed files against the anti-slop rules, and a card must not reach done with slop-gate failing; use 'failed' + a report if anything breaks). If you need something from another session, message THAT SESSION (relay_peers to find its id, relay_send to reach it) — never ask the human to pass it along; carrying messages between agents is the job this bus exists to remove. When your work for THIS message is finished, END YOUR TURN — do NOT park, do NOT loop relay_wait; the runner waits for you and will wake you with the next message.`;
|
|
276
279
|
|
|
277
280
|
// ---- the pulse (Scape's Lloyd/Argus loop, Trantor-shaped) --------------------
|
|
278
281
|
// A message-driven seat is DEAF between messages. An orchestrator seat with a mission needs a
|
|
@@ -353,9 +356,9 @@ function classify(exit) {
|
|
|
353
356
|
return reason;
|
|
354
357
|
}
|
|
355
358
|
|
|
356
|
-
async function reportFailure(exit, trigger, undelivered = 0) {
|
|
359
|
+
async function reportFailure(exit, trigger, undelivered = 0, reasonOverride = "") {
|
|
357
360
|
consecFails++;
|
|
358
|
-
const reason = classify(exit);
|
|
361
|
+
const reason = reasonOverride || classify(exit);
|
|
359
362
|
const down = consecFails >= 2;
|
|
360
363
|
const status = down ? `down: ${reason} · ${consecFails} fails` : `errored: ${reason}`;
|
|
361
364
|
await api("/register", { session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
|
|
@@ -394,6 +397,41 @@ async function reportFailure(exit, trigger, undelivered = 0) {
|
|
|
394
397
|
}
|
|
395
398
|
cmuxStatus(down ? "down" : "error", "#ef6a6a", "alert", { alert: true, priority: 90 }); herdrAgent("blocked"); cmuxLog(`turn failed: ${reason} (exit ${exit})`, "error");
|
|
396
399
|
log(`\x1b[31mreported failure to bus: ${reason} (exit ${exit})\x1b[0m`);
|
|
400
|
+
return reason;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ---- a dead seat is not retried (#6134) -------------------------------------------------------
|
|
404
|
+
// The redelivery ladder assumes the next attempt might work. Against a spent plan or a rejected
|
|
405
|
+
// key it never will, and the cost is real: codex burned 60 turns on 09-02 doing nothing but being
|
|
406
|
+
// redelivered to. So those two reasons PARK — the queue is kept, the ladder stops, and the room is
|
|
407
|
+
// told once, with the reset time when the CLI printed one. `trantor up` (a restart) resumes.
|
|
408
|
+
let parkAnnounced = false;
|
|
409
|
+
async function parkSeat(reason, undelivered) {
|
|
410
|
+
const resetAt = parseResetAt(lastErrText);
|
|
411
|
+
const when = resetAt ? new Date(resetAt).toLocaleString() : "";
|
|
412
|
+
if (!parkAnnounced) {
|
|
413
|
+
parkAnnounced = true;
|
|
414
|
+
const text = redactKeys(`⛔ ${SESSION} PARKED (${reason}) — holding ${undelivered} message(s), redelivery stopped ${when ? `until ${when}` : `until \`trantor up ${AGENT}\``}`);
|
|
415
|
+
await api("/send", { from: SESSION, to: "all", text, project: PROJ, kind: "status" }).catch(() => {});
|
|
416
|
+
const orch = `${hostId()}:${PROJ}`;
|
|
417
|
+
if (orch !== SESSION) await api("/send", { from: SESSION, to: orch, text, project: PROJ, kind: "alert" }).catch(() => {});
|
|
418
|
+
}
|
|
419
|
+
log(`\x1b[31mparked (${reason})${when ? ` — retrying after ${when}` : " — no reset time in the output; waiting for a restart"}\x1b[0m`);
|
|
420
|
+
// No reset time means no timer can clear it: hold until the operator restarts the seat.
|
|
421
|
+
return resetAt || Number.MAX_SAFE_INTEGER;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// The seat's own balance rows, for the #6131 read: a stalled turn that printed nothing on a seat
|
|
425
|
+
// whose plan is spent is exhaustion, not a crash. Bounded and best-effort — a slow provider API
|
|
426
|
+
// must never hold up the failure path, and an unreachable one just leaves the reason as it was.
|
|
427
|
+
async function balanceRows() {
|
|
428
|
+
try {
|
|
429
|
+
const { fetchBalances } = await import("../lib/balances.mjs");
|
|
430
|
+
return await Promise.race([
|
|
431
|
+
fetchBalances(process.env, { only: [AGENT] }),
|
|
432
|
+
new Promise((r) => setTimeout(() => r([]), 4000)),
|
|
433
|
+
]);
|
|
434
|
+
} catch { return []; }
|
|
397
435
|
}
|
|
398
436
|
|
|
399
437
|
// ---- activity truth (#5965): the RUNNER is the source for this seat ----------------
|
|
@@ -444,17 +482,33 @@ async function notifyAssigners(pairs, text) {
|
|
|
444
482
|
async function reportHealthy() {
|
|
445
483
|
if (consecFails === 0) return; // already healthy — don't spam
|
|
446
484
|
consecFails = 0;
|
|
447
|
-
// Recovery is a change too, so the next failure is news again.
|
|
485
|
+
// Recovery is a change too, so the next failure is news again — a park included.
|
|
448
486
|
announced = "";
|
|
487
|
+
parkAnnounced = false;
|
|
449
488
|
await api("/register", { session: SESSION, project: PROJ, status: `active in ${PROJ}`, llm: AGENT, model: MODEL, kind: "agent" }).catch(() => {});
|
|
450
489
|
await api("/send", { from: SESSION, to: "all", text: `✅ ${SESSION} recovered`, project: PROJ, kind: "status" }).catch(() => {});
|
|
451
490
|
cmuxStatus("ok", "#14b8a6", "check"); herdrAgent("idle");
|
|
452
491
|
}
|
|
453
492
|
|
|
493
|
+
// ---- the time box (#6134) --------------------------------------------------------------------
|
|
494
|
+
// A turn with no ceiling is how a seat spends an afternoon on one card: the 09-02 baseline was 151
|
|
495
|
+
// turns and ~16 agentic hours across the fleet. TRANTOR_TURN_MAX_MS ends the CLI's process group
|
|
496
|
+
// at the box and runs ONE follow-up turn in the SAME session — "commit what is done, move the
|
|
497
|
+
// card, report in one line" — so a cut turn lands its work instead of losing it.
|
|
498
|
+
const TURN_MAX_MS = Math.max(0, Number(process.env.TRANTOR_TURN_MAX_MS || 20 * 60 * 1000));
|
|
499
|
+
const TIME_BOX_PROMPT = "your previous turn was cut at the time box; commit what is done, move the card with a note, report in one line";
|
|
500
|
+
let inFollowUp = false;
|
|
501
|
+
// The card the CURRENT CLI session belongs to (#6134). 0 = the kickoff session, which belongs to
|
|
502
|
+
// no card, so the first contract that names one starts a session of its own.
|
|
503
|
+
let sessionCard = 0;
|
|
504
|
+
|
|
454
505
|
let sid = "";
|
|
455
506
|
async function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
456
507
|
TURN++; banner(trigger);
|
|
457
508
|
const t0 = Date.now();
|
|
509
|
+
// A fresh session must not resume the old one's id: `first` is chosen by isFirst OR a missing
|
|
510
|
+
// sid, so a stale sid would quietly resume the session this turn exists to leave behind.
|
|
511
|
+
if (isFirst) sid = "";
|
|
458
512
|
// #5965 — TURN START. The hub peer row is where the app reads activity from, and the runner is
|
|
459
513
|
// the only one who knows a turn is starting, so say so before the CLI spawn (awaited: the spawn
|
|
460
514
|
// below blocks the loop, an unawaited fetch would not leave the machine until the turn ended).
|
|
@@ -510,8 +564,20 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
510
564
|
{ detached: true, stdio: "ignore" });
|
|
511
565
|
wd.unref();
|
|
512
566
|
} catch {}
|
|
513
|
-
|
|
514
|
-
|
|
567
|
+
// Preserve the CLI's exit before waiting for the stderr process substitution. Without the
|
|
568
|
+
// explicit wait, a short failing CLI can return while its error is still in the scrub pipe;
|
|
569
|
+
// under load the classifier then reads an empty ERRF and reports the wrong failure reason.
|
|
570
|
+
const shell = `set -o pipefail; { ${inner} ; } 2> >(${SCRUB} --tee2 ${ERRF}); turn_exit=$?; wait; exit $turn_exit`;
|
|
571
|
+
const r = spawnSync("/bin/bash", ["-c", shell], {
|
|
572
|
+
// detached: bash leads its OWN process group, so the time box can kill the CLI and everything
|
|
573
|
+
// it spawned with one signal instead of orphaning the model process behind a dead shell.
|
|
574
|
+
// stdin is /dev/null for every seat (it already was for codex/kimi/dsh via `< /dev/null`):
|
|
575
|
+
// a detached group is a BACKGROUND group, and a background process that reads the terminal
|
|
576
|
+
// takes SIGTTIN and stops forever. Nothing here runs interactively — every CLI is in -p /
|
|
577
|
+
// exec / run mode — so closing stdin is what makes the group safe.
|
|
578
|
+
detached: true,
|
|
579
|
+
...(TURN_MAX_MS ? { timeout: TURN_MAX_MS, killSignal: "SIGKILL" } : {}),
|
|
580
|
+
cwd: TURN_DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : ["ignore", "inherit", "inherit"],
|
|
515
581
|
env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_SESSION: SESSION, RELAY_PROJECT: PROJ,
|
|
516
582
|
// A RUNNER-MANAGED SEAT MUST NEVER HAND ITSELF A BATON.
|
|
517
583
|
//
|
|
@@ -527,9 +593,16 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
527
593
|
maxBuffer: 16 * 1024 * 1024,
|
|
528
594
|
});
|
|
529
595
|
try { unlinkSync(STAMPF); } catch {} // turn over — disarm the watchdog
|
|
530
|
-
// #
|
|
531
|
-
//
|
|
532
|
-
//
|
|
596
|
+
// #6134: node's timeout signals only the shell it spawned. The CLI and its children share the
|
|
597
|
+
// detached group, so sweep the whole group — otherwise a boxed turn leaves a model still running
|
|
598
|
+
// (and still billing) against a runner that has moved on.
|
|
599
|
+
const cut = !!TURN_MAX_MS && (r.error?.code === "ETIMEDOUT" || (r.signal === "SIGKILL" && Date.now() - t0 >= TURN_MAX_MS));
|
|
600
|
+
if (cut && r.pid) {
|
|
601
|
+
try { process.kill(-r.pid, "SIGKILL"); } catch {}
|
|
602
|
+
log(`\x1b[33mturn cut at the ${Math.round(TURN_MAX_MS / 1000)}s time box — CLI process group ended\x1b[0m`);
|
|
603
|
+
}
|
|
604
|
+
// #5869: scrub AT REST, synchronously, before anything reads the file back. The explicit shell
|
|
605
|
+
// wait above drains the live stderr scrubber first; this pass is defense in depth for redaction.
|
|
533
606
|
try { writeFileSync(ERRF, redactKeys(readFileSync(ERRF, "utf8"))); } catch {}
|
|
534
607
|
// #5868: classify only what the CLI itself said. The transcript replays the whole turn prompt
|
|
535
608
|
// (rules, lessons, the wake text) — and those lines once classified healthy codex turns as
|
|
@@ -575,12 +648,23 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
575
648
|
// #5868: the verdict rides the telemetry row so a classification survives the pane scrolling
|
|
576
649
|
// away — the same "classified X because Y" shape the runner logs, in the seat's jsonl forever.
|
|
577
650
|
const verdict = verdictFor(realExit, effExit, lastEmptyOutput, ownOut);
|
|
578
|
-
|
|
651
|
+
// #6134: what the turn COST, from the CLI's own usage line. Zero means this CLI printed none —
|
|
652
|
+
// never that the turn was free. `trantor seat-why` totals these into today's spend per seat.
|
|
653
|
+
const tokens = parseTurnTokens(ownOut);
|
|
654
|
+
telemetry({ ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, model: MODEL || "cli-default", duration_ms: Date.now() - t0, exit: realExit, effExit, authFailed: effExit !== realExit, emptyOutput: lastEmptyOutput, verdict, ...(tokens ? { tokens } : {}), ...(cut ? { cut: true } : {}) });
|
|
579
655
|
log(`turn ended (exit ${realExit}${effExit !== realExit ? ` → effective ${effExit} (${lastEmptyOutput ? "empty-output" : "auth"})` : ""}, ${((Date.now() - t0) / 1000).toFixed(0)}s)`);
|
|
580
656
|
if (realExit === 0 && effExit === 0) { cmuxStatus("idle", "#8a94a6", "robot"); herdrAgent("idle"); } // finished this turn, waiting for the next
|
|
581
657
|
// #5965 — TURN END. A clean exit means the seat is idle again; say so right away so the app stops
|
|
582
658
|
// pulsing it even before the next /poll heartbeat. Failure keeps reportFailure's down/errored.
|
|
583
659
|
if (realExit === 0 && effExit === 0) await registerStatus("idle");
|
|
660
|
+
// The follow-up rides the SAME session, so the model still has the turn it was cut out of and
|
|
661
|
+
// only has to land it. Exactly one — a follow-up that runs long is itself boxed, and boxing a
|
|
662
|
+
// boxed turn forever is the loop this card exists to end.
|
|
663
|
+
if (cut && !inFollowUp) {
|
|
664
|
+
inFollowUp = true;
|
|
665
|
+
try { return await runTurn(TIME_BOX_PROMPT, false, "time-box follow-up"); }
|
|
666
|
+
finally { inFollowUp = false; }
|
|
667
|
+
}
|
|
584
668
|
return effExit;
|
|
585
669
|
}
|
|
586
670
|
|
|
@@ -652,8 +736,19 @@ function isRunnerSession(session) {
|
|
|
652
736
|
|
|
653
737
|
function shouldWake(message) {
|
|
654
738
|
if (isReceipt(message) || isStatusBroadcast(message)) return false;
|
|
739
|
+
// #6134: the SENDER decides. `wake:false` says "this is context, not a contract" — it batches
|
|
740
|
+
// into the next turn's prompt like a broadcast and never buys a CLI session of its own.
|
|
741
|
+
if (message?.wake === false) return false;
|
|
655
742
|
if (message?.to === SESSION) {
|
|
656
743
|
if (message?.kind === "status") return false;
|
|
744
|
+
// The safety net for every sender that never set the flag: a direct message carrying no card
|
|
745
|
+
// and no instruction is an ack, an FYI or a queue note. Those made up most of the 09-02 burn.
|
|
746
|
+
// Two exemptions, both because the shape net reads WORDS and these carry their meaning in
|
|
747
|
+
// their type: a typed alert (a failure escalation, a bounce), and an OVERSEER warning that got
|
|
748
|
+
// this far — the one chatty overseer kind is already batched by name upstream, so anything
|
|
749
|
+
// still here is file-conflict or linked-activity, which #5760 deliberately kept waking.
|
|
750
|
+
const typed = message?.kind === "alert" || /^🤝 OVERSEER /.test(String(message?.text || ""));
|
|
751
|
+
if (!typed && !isContract(message) && !carriesWork(message?.text)) return false;
|
|
657
752
|
return !isRunnerSession(message?.from) || isContract(message);
|
|
658
753
|
}
|
|
659
754
|
return message?.to === "all"
|
|
@@ -761,7 +856,10 @@ function askedExcerpt(message) {
|
|
|
761
856
|
const rest = msgs.filter(m => !fyi.includes(m));
|
|
762
857
|
const direct = rest.filter(m => m.to === SESSION && shouldWake(m));
|
|
763
858
|
const mentions = rest.filter(m => m.to === "all" && shouldWake(m));
|
|
764
|
-
|
|
859
|
+
// Everything that did not earn a turn still becomes CONTEXT — including a DIRECT message that
|
|
860
|
+
// batched (wake:false, or an ack by shape). Dropping those would trade a token problem for a
|
|
861
|
+
// deafness problem: the seat would never learn what it was told (#6134).
|
|
862
|
+
const bcast = [...rest.filter(m => !direct.includes(m) && !mentions.includes(m)), ...fyi];
|
|
765
863
|
pendingBcast.push(...bcast); // wake-policy: plain broadcasts batch, they don't wake
|
|
766
864
|
const wake = [...direct, ...mentions];
|
|
767
865
|
if (!wake.length) { if (bcast.length) { savePending(pendingWake, pendingBcast); log(`${bcast.length} broadcast(s) batched (no wake) — ${pendingBcast.length} pending`); } continue; }
|
|
@@ -807,22 +905,43 @@ function askedExcerpt(message) {
|
|
|
807
905
|
for (const m of wake) if (m.from && !assigners.some(a => a.from === m.from)) assigners.push({ from: m.from, id: m.id });
|
|
808
906
|
const asked = askedExcerpt(wake[0]);
|
|
809
907
|
const tStart = Date.now();
|
|
908
|
+
// #6134: ONE SESSION PER CARD. A seat that resumes forever carries every card it ever worked
|
|
909
|
+
// into every later turn — qwen's 85.7M tokens were 96.7% cached, i.e. replayed history. The
|
|
910
|
+
// card that moved this wake decides: a different one starts a fresh CLI session, and the seat
|
|
911
|
+
// is told so, because a fresh session remembers nothing and must be sent to its card.
|
|
912
|
+
const card = wake.map(m => cardRef(m.text)).find(Boolean) || 0;
|
|
913
|
+
const fresh = card > 0 && card !== sessionCard;
|
|
914
|
+
if (card) sessionCard = card;
|
|
915
|
+
const freshText = fresh
|
|
916
|
+
? `\n(FRESH SESSION for card #${card} — you are not the session that worked earlier cards and you remember none of them. Read your card first: relay_board with card:${card}.)\n`
|
|
917
|
+
: "";
|
|
810
918
|
const prompt = composedTurn({
|
|
811
|
-
wakeText, ctxText, againText,
|
|
919
|
+
wakeText, ctxText, againText: againText + freshText,
|
|
812
920
|
tailText: "\nAct on what's addressed to you, then end your turn.\n\n",
|
|
813
921
|
rulesText: RULES, lessons,
|
|
814
922
|
});
|
|
815
|
-
const ec = await runTurn(prompt,
|
|
923
|
+
const ec = await runTurn(prompt, fresh, deliveryFails ? `${trigger} (redelivery)` : trigger);
|
|
816
924
|
const secs = Math.round((Date.now() - tStart) / 1000);
|
|
817
925
|
if (ec) {
|
|
818
926
|
deliveryFails++;
|
|
927
|
+
// #6131: a silent turn on a seat whose plan reads spent is exhaustion wearing a crash's
|
|
928
|
+
// clothes. Only that one reason is ever re-read, and only from the seat's own balance rows.
|
|
929
|
+
let reason = classify(ec);
|
|
930
|
+
if (reason === "empty-output") reason = reasonWithBalances(reason, await balanceRows());
|
|
931
|
+
savePending(pendingWake, pendingBcast);
|
|
932
|
+
await reportFailure(ec, "message", pendingWake.length, reason);
|
|
933
|
+
if (PARKING_REASONS.has(reason)) {
|
|
934
|
+
retryAt = await parkSeat(reason, pendingWake.length);
|
|
935
|
+
await notifyAssigners(assigners,
|
|
936
|
+
`⛔ your contract is PARKED on ${SESSION} (${reason}) — not retrying · asked: "${asked}"`);
|
|
937
|
+
lastTurnAt = Date.now();
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
819
940
|
const wait = RETRY_MS[Math.min(deliveryFails - 1, RETRY_MS.length - 1)];
|
|
820
941
|
retryAt = Date.now() + wait;
|
|
821
|
-
savePending(pendingWake, pendingBcast);
|
|
822
|
-
await reportFailure(ec, "message", pendingWake.length);
|
|
823
942
|
// The room hears the broadcast above; the one who is actually blocked hears it directly.
|
|
824
943
|
await notifyAssigners(assigners,
|
|
825
|
-
`⚠️ your contract FAILED on ${SESSION} (exit ${ec}, ${
|
|
944
|
+
`⚠️ your contract FAILED on ${SESSION} (exit ${ec}, ${reason}) · retrying in ${Math.round(wait / 1000)}s · asked: "${asked}"`);
|
|
826
945
|
log(`\x1b[31m${pendingWake.length} message(s) still UNDELIVERED — next attempt in ${Math.round(wait / 1000)}s\x1b[0m`);
|
|
827
946
|
} else {
|
|
828
947
|
pendingWake = []; pendingBcast = []; deliveryFails = 0; retryAt = 0;
|
package/bin/crew.sh
CHANGED
|
@@ -792,12 +792,16 @@ resolve_spec() {
|
|
|
792
792
|
if [ -n "$FIELD" ]; then
|
|
793
793
|
case "$FIELD" in
|
|
794
794
|
*/*) MODEL="$FIELD" ;;
|
|
795
|
-
|
|
795
|
+
# A NATIVE CLI takes its model id verbatim: `claude:opus`, `codex:o3`, `kimi:k2`. The router
|
|
796
|
+
# only knows opencode providers, so it refused `claude:opus` outright (2026-09-03) and the
|
|
797
|
+
# seat silently ran the CLI default, which for claude was the most expensive model on the plan.
|
|
798
|
+
*) if [[ "$AGENT" =~ ^(claude|codex|kimi|gemini)$ ]]; then MODEL="$FIELD"; echo " → $AGENT: model $MODEL (native pin)"; else
|
|
799
|
+
MODEL="$(resolve_model "$AGENT" "$FIELD" "$TASK" "$DIFF")" || {
|
|
796
800
|
echo "[crew] ✗ skipping seat '$AGENT' — model resolution failed for $FIELD ($TASK/$DIFF); remaining seats still launch" >&2
|
|
797
801
|
SKIPPED_SEATS+=("$AGENT: model resolution failed for $FIELD ($TASK/$DIFF)")
|
|
798
802
|
return 1
|
|
799
803
|
}
|
|
800
|
-
echo " → $AGENT: live model $MODEL ($FIELD · $TASK/$DIFF)" ;;
|
|
804
|
+
echo " → $AGENT: live model $MODEL ($FIELD · $TASK/$DIFF)"; fi ;;
|
|
801
805
|
esac
|
|
802
806
|
fi
|
|
803
807
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor genesis-kickoff [<project>] — which boot prompt a WOKEN orchestrator gets (#6112).
|
|
3
|
+
//
|
|
4
|
+
// Two paths into a project (operator ruling 2026-09-02 23:15). Path A, blank: the orchestrator
|
|
5
|
+
// wakes plainly and works iteratively with the operator. Path B, from a brief: the PRD sits in
|
|
6
|
+
// docs/PRD.md and the wake CONVENES the crew review (/trantor:prd-review) before anything is
|
|
7
|
+
// built. Waking an adopted project that has docs/PRD.md and no build cards takes path B too, so
|
|
8
|
+
// a project that parked with its PRD in place needs no re-genesis: the next Wake convenes it.
|
|
9
|
+
//
|
|
10
|
+
// The decision needs two facts only the CLI holds together — the checkout's durable docs/PRD.md
|
|
11
|
+
// and the project's SIGNED board — so it lives here. The desktop app (genesis sheet, sidebar
|
|
12
|
+
// Wake, workspace open) runs this in the checkout and relays the one line it prints; on exit 1
|
|
13
|
+
// the app types its own plain wake instead. A board that cannot be read therefore fails CLOSED
|
|
14
|
+
// to the plain wake, never to a review nobody verified was due: the plain-woken orchestrator
|
|
15
|
+
// still sees docs/PRD.md and the CLAUDE.md pointer and can convene by hand.
|
|
16
|
+
import { existsSync } from "node:fs";
|
|
17
|
+
import { join, resolve } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { loadIdentity, signedGet } from "../hooks/lib/api.mjs";
|
|
20
|
+
import { ensureEnrolled as enrollViaOwnerInvite } from "../lib/enroll.mjs";
|
|
21
|
+
import { hostId, resolveHub, resolveProject } from "../lib/project.mjs";
|
|
22
|
+
|
|
23
|
+
export const PRD_REVIEW_KICKOFF = "docs/PRD.md is the brief; run /trantor:prd-review";
|
|
24
|
+
// Word for word the app's WAKE_KICKOFF_PROMPT (desktop terminal.rs): a project without a brief to
|
|
25
|
+
// review gets exactly the wake it always got.
|
|
26
|
+
export const PLAIN_WAKE_KICKOFF = "You were just woken via Trantor. Catch up from your context — the handoff you were handed if one exists, otherwise the project board and memory — then recap where things stand in at most 3 sentences and wait.";
|
|
27
|
+
|
|
28
|
+
// Cards that exist BEFORE a build starts, and must not be mistaken for one: the genesis card
|
|
29
|
+
// `trantor new` opens, the review cards this flow opens, and the auto-cards a session sheds
|
|
30
|
+
// (operator prompts, sub-agents) which say a conversation happened, not that work was cut.
|
|
31
|
+
const PRE_BUILD_PHASES = new Set(["genesis", "prd", "tdd"]);
|
|
32
|
+
const PRE_BUILD_TITLE = /^(genesis:|prd review:|tdd review:)/i;
|
|
33
|
+
const CONVERSATION_SOURCES = new Set(["session", "cc-subagent", "cc-bg-agent"]);
|
|
34
|
+
|
|
35
|
+
export function isBuildCard(task) {
|
|
36
|
+
if (!task || typeof task !== "object") return false;
|
|
37
|
+
const phase = String(task.phase || "").trim().toLowerCase();
|
|
38
|
+
if (phase === "build") return true;
|
|
39
|
+
if (PRE_BUILD_PHASES.has(phase)) return false;
|
|
40
|
+
if (CONVERSATION_SOURCES.has(String(task.source || "").trim().toLowerCase())) return false;
|
|
41
|
+
const title = String(task.title || "").trim();
|
|
42
|
+
return Boolean(title) && !PRE_BUILD_TITLE.test(title);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The pure decision: dir = the checkout, tasks = the board (an array), or null when it could not
|
|
46
|
+
// be read. Exported so the drill can pin every branch without a hub.
|
|
47
|
+
export function selectGenesisKickoff({ dir, tasks }) {
|
|
48
|
+
if (!existsSync(join(dir, "docs", "PRD.md"))) return PLAIN_WAKE_KICKOFF;
|
|
49
|
+
if (!Array.isArray(tasks)) return PLAIN_WAKE_KICKOFF;
|
|
50
|
+
return tasks.some(isBuildCard) ? PLAIN_WAKE_KICKOFF : PRD_REVIEW_KICKOFF;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function readBoard(project) {
|
|
54
|
+
const hub = resolveHub(project);
|
|
55
|
+
// Sign as the identity the orchestrator in this checkout will use (RELAY_SESSION when a runner
|
|
56
|
+
// set one, else host:project). On an enforce hub that identity may be brand new for a project
|
|
57
|
+
// `trantor new` made seconds ago, and TOFU is refused there — so enrol the way crew seats and
|
|
58
|
+
// genesis itself do: the operator's owner key mints a project-scoped invite and this identity
|
|
59
|
+
// spends it. A no-op when the hub already knows us; a soft failure when it does not, in which
|
|
60
|
+
// case the signed read below reports the refusal and the caller falls back.
|
|
61
|
+
const session = process.env.RELAY_SESSION
|
|
62
|
+
|| (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
|
|
63
|
+
const identity = loadIdentity(session);
|
|
64
|
+
const enrolment = await enrollViaOwnerInvite(hub, identity, project, { timeoutMs: 4000 });
|
|
65
|
+
if (!enrolment.ok && enrolment.reason !== "no-owner-key") {
|
|
66
|
+
console.error(`genesis kickoff: enrolment on ${hub} did not succeed (${enrolment.reason}); trying the read anyway`);
|
|
67
|
+
}
|
|
68
|
+
const response = await signedGet(`/tasks?project=${encodeURIComponent(project)}`, { session, project, timeoutMs: 4000 });
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
return { ok: false, hub, reason: response.status ? `hub ${response.status}${response.json?.error ? `: ${response.json.error}` : ""}` : "unreachable" };
|
|
71
|
+
}
|
|
72
|
+
const tasks = Array.isArray(response.json) ? response.json : response.json?.tasks;
|
|
73
|
+
return Array.isArray(tasks) ? { ok: true, hub, tasks } : { ok: false, hub, reason: "malformed /tasks response" };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function main() {
|
|
77
|
+
const dir = process.cwd();
|
|
78
|
+
const project = process.argv[2] || resolveProject(dir);
|
|
79
|
+
if (!existsSync(join(dir, "docs", "PRD.md"))) {
|
|
80
|
+
console.log(PLAIN_WAKE_KICKOFF);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const board = await readBoard(project);
|
|
84
|
+
if (!board.ok) {
|
|
85
|
+
console.error(`genesis kickoff: docs/PRD.md is present but ${project}'s board on ${board.hub} could not be read (${board.reason}) — the app falls back to the plain wake`);
|
|
86
|
+
process.exitCode = 1;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
console.log(selectGenesisKickoff({ dir, tasks: board.tasks }));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
|
package/bin/new.mjs
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// It makes the project directory at <parent>/<name> — --dir names the PARENT, never the project
|
|
7
7
|
// directory itself (default parent: TRANTOR_DEV_ROOT or ~/development). The name is always
|
|
8
8
|
// appended under it, so `--dir P` with name N creates P/N. Starts git on main (or clones --from,
|
|
9
|
-
// or adopts an existing folder with --adopt),
|
|
10
|
-
//
|
|
9
|
+
// or adopts an existing folder with --adopt), stores the brief in docs/PRD.md and seeds a small
|
|
10
|
+
// CLAUDE.md pointer plus the trantor conventions block, installs the same auto-card hook as
|
|
11
11
|
// `trantor init-hooks`, posts the brief as the hub project brief (POST /project — the same call
|
|
12
12
|
// relay_project_brief makes), and opens the first card "genesis: <name>" on the new board.
|
|
13
13
|
//
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import { spawnSync } from "node:child_process";
|
|
18
18
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, appendFileSync } from "node:fs";
|
|
19
19
|
import { homedir } from "node:os";
|
|
20
|
-
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
20
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
21
21
|
import { fileURLToPath } from "node:url";
|
|
22
22
|
import { ensureEnrolled as enrollTofu, loadIdentity, signedPost } from "../hooks/lib/api.mjs";
|
|
23
23
|
import { ensureEnrolled as enrollViaOwnerInvite } from "../lib/enroll.mjs";
|
|
@@ -28,7 +28,7 @@ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
|
28
28
|
|
|
29
29
|
// The trantor conventions block — what every trantor-wired project's CLAUDE.md carries so the
|
|
30
30
|
// first session knows the board, the crew, and the gates exist. Kept SHORT and factual; the
|
|
31
|
-
// brief
|
|
31
|
+
// project brief stays in docs/PRD.md so a large PRD cannot exceed the harness instruction limit.
|
|
32
32
|
const CONVENTIONS = [
|
|
33
33
|
"",
|
|
34
34
|
"## Trantor conventions",
|
|
@@ -94,13 +94,22 @@ if (from) {
|
|
|
94
94
|
branch = git(["branch", "--show-current"]).stdout.trim() || "main";
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
// ──
|
|
97
|
+
// ── durable brief + small CLAUDE.md pointer ─────────────────────────────────────────────────────
|
|
98
|
+
if (brief) {
|
|
99
|
+
const docs = join(dir, "docs");
|
|
100
|
+
mkdirSync(docs, { recursive: true });
|
|
101
|
+
writeFileSync(join(docs, "PRD.md"), `${brief}\n`);
|
|
102
|
+
}
|
|
103
|
+
|
|
98
104
|
const claude = join(dir, "CLAUDE.md");
|
|
99
105
|
if (existsSync(claude)) {
|
|
100
106
|
const current = readFileSync(claude, "utf8");
|
|
101
107
|
if (!current.includes("## Trantor conventions")) appendFileSync(claude, `\n${CONVENTIONS}\n`);
|
|
102
108
|
} else {
|
|
103
|
-
const
|
|
109
|
+
const importedFrom = briefFile ? ` (imported from \`${basename(briefFile)}\`)` : "";
|
|
110
|
+
const head = brief
|
|
111
|
+
? `# ${name}\n\nThe project brief is stored at \`docs/PRD.md\`${importedFrom}. Read and maintain it there.\n`
|
|
112
|
+
: `# ${name}\n\nNo project brief was supplied. Begin with the operator's first instruction.\n`;
|
|
104
113
|
writeFileSync(claude, `${head}${CONVENTIONS}`);
|
|
105
114
|
}
|
|
106
115
|
|
|
@@ -161,7 +170,9 @@ if (json) {
|
|
|
161
170
|
console.log(JSON.stringify({ name, parent: devRoot, dir, branch, hub, card }));
|
|
162
171
|
} else {
|
|
163
172
|
console.log(`✓ ${dir} (${branch}${from ? ", cloned" : adopt ? ", adopted" : ""})`);
|
|
164
|
-
console.log(
|
|
173
|
+
console.log(brief
|
|
174
|
+
? "✓ docs/PRD.md seeded from the brief; CLAUDE.md kept small"
|
|
175
|
+
: "✓ blank project seeded; CLAUDE.md kept small");
|
|
165
176
|
console.log(`✓ auto-card hook installed (trantor init-hooks)`);
|
|
166
177
|
if (card !== null) console.log(`✓ hub ${hub}: brief posted, card #${card} ("genesis: ${name}")`);
|
|
167
178
|
else if (hubError) console.log(`! hub ${hub}: brief/card not posted (${hubError})`);
|
package/bin/seat-why.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// trantor seat-why <agent> [--json] — explain why a crew seat is (not) working, straight from
|
|
3
3
|
// local ~/.agent-bus evidence. No hub needed; works even when the whole fleet is down.
|
|
4
|
-
import { seatWhy } from "../lib/seat-why.mjs";
|
|
4
|
+
import { seatWhy, fmtSpend } from "../lib/seat-why.mjs";
|
|
5
5
|
import { resolveProject } from "../lib/project.mjs";
|
|
6
6
|
|
|
7
7
|
const args = process.argv.slice(2);
|
|
@@ -23,5 +23,6 @@ if (asJson) {
|
|
|
23
23
|
} else {
|
|
24
24
|
console.log(`seat ${agent}:${project} -> ${out.state}`);
|
|
25
25
|
console.log(`why: ${out.why}`);
|
|
26
|
+
console.log(`today: ${fmtSpend(out.today)}`);
|
|
26
27
|
console.log(`advice: ${out.advice}`);
|
|
27
28
|
}
|
package/hub.mjs
CHANGED
|
@@ -2786,7 +2786,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
2786
2786
|
// side, which is how an orchestrator ends up waiting forever on a dead peer.
|
|
2787
2787
|
const re = Number.isFinite(Number(b.re)) && Number(b.re) > 0 ? Number(b.re) : 0;
|
|
2788
2788
|
const kind = String(b.kind || "").slice(0, 40);
|
|
2789
|
-
|
|
2789
|
+
// `wake:false` is the SENDER saying this message is context, not a contract: the receiving
|
|
2790
|
+
// runner batches it into that seat's next turn instead of spending a whole CLI session on
|
|
2791
|
+
// it (#6134). Stored only when false — absent means wake, so every older client is unchanged.
|
|
2792
|
+
const wake = b.wake === false ? { wake: false } : {};
|
|
2793
|
+
const msg = { id: ++state.seq, ts: now(), from: b.from || "anon", to: b.to || "all", text, project: String(b.project || fromProj || "").slice(0, 80), ...(re ? { re } : {}), ...(kind ? { kind } : {}), ...wake };
|
|
2790
2794
|
state.messages.push(msg); if (state.messages.length > 5000) state.messages.splice(0, 1000);
|
|
2791
2795
|
dirty = true; pushToStreams(msg); // <-- instant push to live watchers
|
|
2792
2796
|
// Mirror onto the unified log. `refs` = the card ids this message cites (#3701), which is what
|
package/lib/seat-why.mjs
CHANGED
|
@@ -61,6 +61,32 @@ const rel = (ts) => {
|
|
|
61
61
|
return `${Math.round(s / 3600)}h ago`;
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
+
// What this seat has SPENT today (#6134). The turn count is the number that mattered on 09-02:
|
|
65
|
+
// 151 turns and ~16 agentic hours across the fleet, most of it redelivery and coordination noise.
|
|
66
|
+
// Tokens come from each CLI's own usage line (the runner parses it onto the turn's row); CLIs that
|
|
67
|
+
// print none contribute 0, which is why the count is reported alongside — "3 of 7 turns reported".
|
|
68
|
+
export function todaySpend(telemetry, now = Date.now()) {
|
|
69
|
+
const midnight = new Date(now); midnight.setHours(0, 0, 0, 0);
|
|
70
|
+
const rows = telemetry.filter(r => typeof r.turn === "number" && r.ts >= midnight.getTime());
|
|
71
|
+
const withTokens = rows.filter(r => Number(r.tokens) > 0);
|
|
72
|
+
return {
|
|
73
|
+
turns: rows.length,
|
|
74
|
+
minutes: Math.round(rows.reduce((a, r) => a + (Number(r.duration_ms) || 0), 0) / 60000),
|
|
75
|
+
tokens: withTokens.reduce((a, r) => a + Number(r.tokens), 0),
|
|
76
|
+
reported: withTokens.length,
|
|
77
|
+
cut: rows.filter(r => r.cut).length,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function fmtSpend(s) {
|
|
82
|
+
const bits = [`${s.turns} turn${s.turns === 1 ? "" : "s"}`, `${s.minutes}m`];
|
|
83
|
+
bits.push(s.reported
|
|
84
|
+
? `${s.tokens.toLocaleString("en-US")} tokens (${s.reported}/${s.turns} turns reported)`
|
|
85
|
+
: "tokens not reported by this CLI");
|
|
86
|
+
if (s.cut) bits.push(`${s.cut} cut at the time box`);
|
|
87
|
+
return bits.join(" · ");
|
|
88
|
+
}
|
|
89
|
+
|
|
64
90
|
export function seatWhy(project, agent, opts = {}) {
|
|
65
91
|
const dir = opts.dir || busDir();
|
|
66
92
|
const errText = readF(join(dir, `err-${agent}-${project}.txt`));
|
|
@@ -117,7 +143,7 @@ export function seatWhy(project, agent, opts = {}) {
|
|
|
117
143
|
}
|
|
118
144
|
}
|
|
119
145
|
|
|
120
|
-
return { state, why, advice };
|
|
146
|
+
return { state, why, advice, today: todaySpend(telemetry, opts.now) };
|
|
121
147
|
}
|
|
122
148
|
|
|
123
149
|
export { fmt, rel };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Per-turn policy for a crew seat (#6134) — the rules that decide whether a bus message is worth
|
|
2
|
+
// a CLI turn at all, which card a session belongs to, what a turn cost, and when an exhausted seat
|
|
3
|
+
// is allowed to be woken again.
|
|
4
|
+
//
|
|
5
|
+
// These live here rather than in bin/crew-runner.mjs because the runner is a self-executing script
|
|
6
|
+
// (top-level await, spawns turns on import), so nothing in it can be unit-tested directly. The
|
|
7
|
+
// drill still drives the REAL runner end to end; these functions are what it can assert on cheaply.
|
|
8
|
+
//
|
|
9
|
+
// The burn this exists to stop: on 09-02 the fleet spent 151 turns and ~16 agentic hours, codex
|
|
10
|
+
// alone taking 60 turns of pure redelivery, and qwen 85.7M tokens at 96.7% cached — i.e. the same
|
|
11
|
+
// history replayed over and over. Every rule below removes one class of turn that never had work
|
|
12
|
+
// in it.
|
|
13
|
+
|
|
14
|
+
/// The first card a message cites. A turn belongs to exactly one card, and this is how the runner
|
|
15
|
+
/// knows when a wake has moved to a different one.
|
|
16
|
+
export const CARD_REF_RE = /#(\d{1,7})(?!\d)/;
|
|
17
|
+
|
|
18
|
+
/// Words that make a direct message an instruction rather than conversation. Deliberately short:
|
|
19
|
+
/// the point is to catch a contract that forgot to cite its card, not to parse English.
|
|
20
|
+
export const IMPERATIVE_RE = /\b(deliver|fix|bounce|contract|next|resume)\b/i;
|
|
21
|
+
|
|
22
|
+
export function cardRef(text) {
|
|
23
|
+
const m = CARD_REF_RE.exec(String(text || ""));
|
|
24
|
+
return m ? Number(m[1]) : 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function hasImperative(text) {
|
|
28
|
+
return IMPERATIVE_RE.test(String(text || ""));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// The safety net for a sender that never set `wake`: a direct message with no card and no
|
|
32
|
+
/// imperative is an ack, an FYI or a queue note, and it batches into the next turn's context.
|
|
33
|
+
export function carriesWork(text) {
|
|
34
|
+
return cardRef(text) > 0 || hasImperative(text);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---- what a turn cost -------------------------------------------------------------------------
|
|
38
|
+
// Each CLI reports its own usage in its own words, and some report none at all. Ordered most
|
|
39
|
+
// specific first; the LAST match of the first pattern that hits wins, because a CLI that prints a
|
|
40
|
+
// running total prints the real one last. Zero means "this CLI said nothing", never "free".
|
|
41
|
+
const TOKEN_PATTERNS = [
|
|
42
|
+
/tokens used[:\s]+([\d,]+)/gi, // codex
|
|
43
|
+
/\btotal tokens[:\s]+([\d,]+)/gi, // opencode / glm / deepseek summaries
|
|
44
|
+
/\btokens[:\s]+([\d,]+)/gi, // "Tokens: 12,345"
|
|
45
|
+
/([\d,]+)\s+tokens\b/gi, // "12,345 tokens"
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export function parseTurnTokens(text) {
|
|
49
|
+
const s = String(text || "");
|
|
50
|
+
for (const re of TOKEN_PATTERNS) {
|
|
51
|
+
re.lastIndex = 0;
|
|
52
|
+
let last = 0;
|
|
53
|
+
for (const m of s.matchAll(re)) {
|
|
54
|
+
const n = Number(String(m[1]).replace(/,/g, ""));
|
|
55
|
+
if (Number.isFinite(n)) last = n;
|
|
56
|
+
}
|
|
57
|
+
if (last) return last;
|
|
58
|
+
}
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---- when an exhausted seat may be woken again ------------------------------------------------
|
|
63
|
+
// A CLI that hits its plan wall usually says when the wall lifts. Parsing it turns a blind retry
|
|
64
|
+
// ladder (60 redelivery turns on codex, 09-02) into one wait.
|
|
65
|
+
const MONTHS = "jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec";
|
|
66
|
+
const RESET_ABS_RE = new RegExp(
|
|
67
|
+
String.raw`try again (?:at|on|after)\s+((?:${MONTHS})[a-z]*\.?\s+\d{1,2}(?:st|nd|rd|th)?,?\s+\d{4},?\s+\d{1,2}:\d{2}(?::\d{2})?\s*(?:am|pm)?)`,
|
|
68
|
+
"i",
|
|
69
|
+
);
|
|
70
|
+
const RESET_REL_RE = /try again in\s+(\d+)\s*(second|minute|hour|day)s?/i;
|
|
71
|
+
|
|
72
|
+
/// The epoch ms at which this seat may be retried, or 0 when the output named no time.
|
|
73
|
+
export function parseResetAt(text, now = Date.now()) {
|
|
74
|
+
const s = String(text || "");
|
|
75
|
+
|
|
76
|
+
const abs = RESET_ABS_RE.exec(s);
|
|
77
|
+
if (abs) {
|
|
78
|
+
// "Sep 3rd, 2026 3:34 AM" — Date.parse rejects the ordinal suffix, so drop it.
|
|
79
|
+
const t = Date.parse(abs[1].replace(/(\d{1,2})(st|nd|rd|th)/i, "$1"));
|
|
80
|
+
if (Number.isFinite(t) && t > now) return t;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const rel = RESET_REL_RE.exec(s);
|
|
84
|
+
if (rel) {
|
|
85
|
+
const unit = { second: 1e3, minute: 60e3, hour: 3600e3, day: 86400e3 }[rel[2].toLowerCase()];
|
|
86
|
+
if (unit) return now + Number(rel[1]) * unit;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// #6131: a qwen seat whose token plan is spent does not error — it stalls and returns nothing, so
|
|
93
|
+
/// the runner classified it `empty-output` and kept the ladder running against a wall. A silent
|
|
94
|
+
/// turn on a seat whose own balance row reads spent IS exhaustion, and parks like one.
|
|
95
|
+
export function quotaSpent(rows) {
|
|
96
|
+
return (Array.isArray(rows) ? rows : []).some((r) => {
|
|
97
|
+
if (!r || !r.ok) return false;
|
|
98
|
+
if (r.kind === "quota") return r.remainingPct != null && r.remainingPct <= 0;
|
|
99
|
+
if (r.kind === "windows") {
|
|
100
|
+
return (r.windows || []).some((w) => w.locked || (w.usedPct != null && w.usedPct >= 100));
|
|
101
|
+
}
|
|
102
|
+
return r.remaining != null && r.remaining <= 0;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/// The failure reason a turn should be treated as, given what the seat's balances say. Only
|
|
107
|
+
/// `empty-output` is ever re-read this way: every other reason already carries its own evidence.
|
|
108
|
+
export function reasonWithBalances(reason, rows) {
|
|
109
|
+
return reason === "empty-output" && quotaSpent(rows) ? "exhausted" : reason;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// Seats that park rather than retry. A backend error is the provider having a bad minute and the
|
|
113
|
+
/// ladder is exactly right for it; a spent plan or a rejected key will not fix itself on a timer.
|
|
114
|
+
export const PARKING_REASONS = new Set(["exhausted", "auth"]);
|
package/mcp.mjs
CHANGED
|
@@ -314,12 +314,55 @@ server.tool("relay_withdraw_proposal", "Withdraw one of THIS session's PENDING p
|
|
|
314
314
|
return { content: [{ type: "text", text: `proposal #${id} withdrawn — one queue slot free` }] };
|
|
315
315
|
});
|
|
316
316
|
|
|
317
|
+
// ONE card, not the board (#6134). A seat used to be told to "query the board for related PAST
|
|
318
|
+
// cards and lessons — 1900+ cards of tribal knowledge", and it did: the whole board, every turn,
|
|
319
|
+
// almost all of it other people's work. This is the same intent at a thousandth of the tokens —
|
|
320
|
+
// the card, what it waits on, its own notes, and the handful of done cards that actually rhyme
|
|
321
|
+
// with it. Client-side on /tasks, so no hub change and it works against any hub version.
|
|
322
|
+
const STOPWORDS = new Set(["the","a","an","and","or","of","to","in","on","for","with","is","it","its","that","this","not","but","by","at","as","from","into","out","up","down","when","then","than","so","no","new","one","two","every","all","any"]);
|
|
323
|
+
function titleWords(title) {
|
|
324
|
+
return new Set(String(title || "").toLowerCase().match(/[a-z][a-z0-9_-]{2,}/g)?.filter(w => !STOPWORDS.has(w)) || []);
|
|
325
|
+
}
|
|
326
|
+
function cardView(tasks, id, proj) {
|
|
327
|
+
const card = tasks.find(t => t.id === id);
|
|
328
|
+
if (!card) return `${proj}: no card #${id}`;
|
|
329
|
+
const out = [`#${card.id} ${card.title}`,
|
|
330
|
+
`status: ${card.status}${card.assignee ? ` · @${card.assignee}` : ""}${card.difficulty ? ` · ${card.difficulty}` : ""}${card.model ? ` · ${card.model}` : ""}`];
|
|
331
|
+
|
|
332
|
+
const deps = (Array.isArray(card.deps) ? card.deps : []).map(d => {
|
|
333
|
+
const t = tasks.find(x => x.id === d);
|
|
334
|
+
return t ? `#${t.id} ${t.title} [${t.status}]` : `#${d} (not on this board)`;
|
|
335
|
+
});
|
|
336
|
+
if (deps.length) out.push(`depends on:\n ${deps.join("\n ")}`);
|
|
337
|
+
|
|
338
|
+
if (Array.isArray(card.checklist) && card.checklist.length) {
|
|
339
|
+
out.push(`checklist:\n ${card.checklist.map((c, i) => `[${c.done ? "x" : " "}] ${i}. ${c.text || c}`).join("\n ")}`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const log = Array.isArray(card.log) ? card.log : [];
|
|
343
|
+
if (log.length) out.push(`notes (${log.length}):\n ${log.map(e => `${e.by || "?"}: ${String(e.text || "").replace(/\s+/g, " ")}`).join("\n ")}`);
|
|
344
|
+
|
|
345
|
+
// The prior art that is actually prior art: done cards whose title shares a real word with this
|
|
346
|
+
// one. Five, newest first — enough to catch "we already did this", short enough to stay cheap.
|
|
347
|
+
const mine = titleWords(card.title);
|
|
348
|
+
const kin = tasks
|
|
349
|
+
.filter(t => t.status === "done" && t.id !== card.id && [...titleWords(t.title)].some(w => mine.has(w)))
|
|
350
|
+
.sort((a, b) => (b.updated || b.ts || 0) - (a.updated || a.ts || 0))
|
|
351
|
+
.slice(0, 5)
|
|
352
|
+
.map(t => `#${t.id} ${t.title}${t.log?.length ? ` ·${t.log.length}` : ""}`);
|
|
353
|
+
if (kin.length) out.push(`related done cards:\n ${kin.join("\n ")}`);
|
|
354
|
+
|
|
355
|
+
return out.join("\n");
|
|
356
|
+
}
|
|
357
|
+
|
|
317
358
|
server.tool("relay_board", "Show a project's Kanban board (all cards + their status + assignee). Defaults to THIS project; pass `project` to read a crew board you orchestrate from elsewhere. Cards carrying log notes show a ·N count (the card's note-log size).",
|
|
318
|
-
{ project: z.string().optional().describe("board to show (default: this session's project)")
|
|
319
|
-
|
|
359
|
+
{ project: z.string().optional().describe("board to show (default: this session's project)"),
|
|
360
|
+
card: z.number().optional().describe("read ONE card instead of the board: the card itself, its notes, and the last five done cards whose title shares a word with it. This is what a seat starting a card should call — the whole board is 1900+ cards of someone else's work.") },
|
|
361
|
+
async ({ project, card }) => {
|
|
320
362
|
const proj = project || PROJECT;
|
|
321
363
|
const { tasks } = await api("GET", `/tasks?project=${encodeURIComponent(proj)}`);
|
|
322
364
|
if (!tasks.length) return { content: [{ type: "text", text: `${proj}: no cards yet` }] };
|
|
365
|
+
if (card) return { content: [{ type: "text", text: cardView(tasks, card, proj) }] };
|
|
323
366
|
const by = { todo: [], doing: [], testing: [], failed: [], done: [], blocked: [] };
|
|
324
367
|
for (const t of tasks) (by[t.status] || by.todo).push(`#${t.id} ${t.title}${t.assignee ? ` (@${t.assignee})` : ""}${t.log?.length ? ` ·${t.log.length}` : ""}`);
|
|
325
368
|
const cols = Object.entries(by).filter(([, v]) => v.length).map(([k, v]) => `${k.toUpperCase()}:\n ${v.join("\n ")}`);
|
|
@@ -338,16 +381,17 @@ server.tool("relay_peers", "Find who you can talk to: the live agent sessions on
|
|
|
338
381
|
});
|
|
339
382
|
|
|
340
383
|
server.tool("relay_send", "Send a live message to another agent session (or 'all' to broadcast). Reach the other agent YOURSELF: if you are about to ask the human to pass something along, tell the session directly instead — asking a person to carry a message between two agents is a failure, not politeness. Don't know the id? relay_peers lists them, linked projects included. Cross-project sends are allowed.",
|
|
341
|
-
{ to: z.string().describe("target session id, or 'all'"), text: z.string().describe("message body")
|
|
342
|
-
|
|
384
|
+
{ to: z.string().describe("target session id, or 'all'"), text: z.string().describe("message body"),
|
|
385
|
+
wake: z.boolean().optional().describe("false = context, not a contract: the message batches into the target's next turn instead of buying it a whole CLI session. Use it for acks, FYIs and queue notes; leave it unset for anything you expect worked on.") },
|
|
386
|
+
async ({ to, text, wake }) => {
|
|
343
387
|
// The event log is append-only — a secret in it is unrecoverable, so refuse BEFORE
|
|
344
388
|
// anything reaches the hub. Returns the offending kinds so the caller can fix it.
|
|
345
389
|
const scrub = assertNoSecrets(text);
|
|
346
390
|
if (!scrub.ok) {
|
|
347
391
|
return { content: [{ type: "text", text: `REFUSED — not sent. Credential-shaped string(s) detected: ${scrub.kinds.join(", ")}. Remove them and resend.` }], isError: true };
|
|
348
392
|
}
|
|
349
|
-
const { id } = await api("POST", "/send", { from: SESSION, to, text });
|
|
350
|
-
return { content: [{ type: "text", text: `sent #${id} to ${to}` }] };
|
|
393
|
+
const { id } = await api("POST", "/send", { from: SESSION, to, text, ...(wake === false ? { wake: false } : {}) });
|
|
394
|
+
return { content: [{ type: "text", text: `sent #${id} to ${to}${wake === false ? " (batched — no turn)" : ""}` }] };
|
|
351
395
|
});
|
|
352
396
|
|
|
353
397
|
server.tool("relay_status", "Set this session's one-line status on the presence board (what you're working on / idle). Cheap — other sessions read it instantly via relay_peers without messaging you.",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.35",
|
|
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-cursor-rewind.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-persist-safety.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-crew-model-defaults.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"
|
|
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-cursor-rewind.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-persist-safety.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-crew-model-defaults.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-prd-review.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": [
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: prd-review
|
|
3
|
+
description: |
|
|
4
|
+
Convene the crew's review of a project's brief (docs/PRD.md) and then of its design
|
|
5
|
+
(docs/TDD.md): every live crew seat plus two Scrooge readers review the document independently
|
|
6
|
+
against one rubric, the orchestrator synthesizes their verdicts into one consensus and puts it
|
|
7
|
+
to the operator, and the build cards open only after the TDD passes. Use when a wake says
|
|
8
|
+
"docs/PRD.md is the brief; run /trantor:prd-review", when asked to have the crew review a PRD
|
|
9
|
+
or a TDD, or to resume at the TDD phase. Trigger: /trantor:prd-review [prd|tdd]
|
|
10
|
+
user-invocable: true
|
|
11
|
+
argument-hint: "[prd|tdd]"
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# PRD review: the crew reads the brief before anyone builds
|
|
15
|
+
|
|
16
|
+
Ruled by the operator on 2026-09-02: every PRD that gets ingested is reviewed by as many members
|
|
17
|
+
of the crew as are live, the crew reaches consensus on whether it is good enough to move on to the
|
|
18
|
+
TDD, the TDD gets the same review, and only then does the build start. A solo orchestrator that
|
|
19
|
+
recaps the brief and proposes a plan is exactly what this replaces: one reader's blind spots become
|
|
20
|
+
the product's. Lateral cross-review is the reliability argument, so the reviews are INDEPENDENT.
|
|
21
|
+
|
|
22
|
+
You are the project's ORCHESTRATOR. You convene, you dispatch, you synthesize, you record. You do
|
|
23
|
+
not review the document yourself and you never vote: a verdict of yours would be one more opinion
|
|
24
|
+
from the one seat that also decides what the opinions mean.
|
|
25
|
+
|
|
26
|
+
Phase argument: none or `prd` runs the PRD phase and continues into the TDD phase on a pass;
|
|
27
|
+
`tdd` resumes at the TDD phase when the PRD card is already done (a turn or a session ended in
|
|
28
|
+
between).
|
|
29
|
+
|
|
30
|
+
## 0. The board first
|
|
31
|
+
|
|
32
|
+
1. `relay_board`. A card titled `PRD review: <project>` (for `tdd`: `TDD review: <project>`)
|
|
33
|
+
that is not done is THE card: continue on it, never open a second one. Its notes hold the
|
|
34
|
+
roster and every verdict so far.
|
|
35
|
+
2. `docs/PRD.md` must exist in the checkout. If it does not, say so and stop: there is nothing to
|
|
36
|
+
review, and a review of a brief that is not written down is not a review.
|
|
37
|
+
|
|
38
|
+
## 1. Establish the reviewers
|
|
39
|
+
|
|
40
|
+
The reviewers are, by the ruling, every live seat of the project's crew plus two Scrooge readers
|
|
41
|
+
on two different models.
|
|
42
|
+
|
|
43
|
+
1. **Crew seats.** `relay_peers` lists the live sessions. A reviewer is every online
|
|
44
|
+
`<cli>:<project>` seat of THIS project (`codex:<project>`, `glm:<project>`, `kimi:<project>`,
|
|
45
|
+
…). Not reviewers: you, the operator's host session (`<host>:<project>`), and tool
|
|
46
|
+
identities such as `genesis:<project>`.
|
|
47
|
+
2. **No crew live?** Bring one up FIRST through `/trantor:crew`: `relay_advise` with the review
|
|
48
|
+
as the work, then `trantor up …` with the SUBSCRIPTION seats the operator's profile declares
|
|
49
|
+
(`trantor profile`), and read the launcher's verdict: only a seat that verified on the bus
|
|
50
|
+
reviews. Choosing the brief path already authorized a review crew; do not ask again, and do
|
|
51
|
+
not substitute metered API seats the profile did not select.
|
|
52
|
+
3. **Two Scrooge readers.** `relay_scrooge` twice with the identical rubric and the full text of
|
|
53
|
+
`docs/PRD.md` in the prompt (the cheap model sees only the prompt; use `task: "reason"`, or
|
|
54
|
+
`task: "long-context"` for a brief beyond about 40k characters). Each receipt names its
|
|
55
|
+
`provider/model`; the two must differ. If the second lands on the same model, retry it at a
|
|
56
|
+
different `difficulty` (the router escalates to a different model). If two distinct models
|
|
57
|
+
cannot be had, proceed with one and say so on the card: never describe one model twice as two
|
|
58
|
+
independent readers.
|
|
59
|
+
|
|
60
|
+
The roster (seat ids plus the two `provider/model` receipts) goes into the review card's opening
|
|
61
|
+
note and is FROZEN: the TDD phase uses the same seats and the same two models. A seat that dies
|
|
62
|
+
between phases is restored with `trantor up`, not replaced by a different reviewer.
|
|
63
|
+
|
|
64
|
+
## 2. The rubric
|
|
65
|
+
|
|
66
|
+
One rubric for every reviewer, in this order, in these words:
|
|
67
|
+
|
|
68
|
+
1. **completeness** — what the brief covers and what it leaves unsaid;
|
|
69
|
+
2. **ambiguity** — statements that two builders would implement differently;
|
|
70
|
+
3. **feasibility and risk** — what is hard, what could fail, what depends on the unknown;
|
|
71
|
+
4. **missing requirements** — what the brief needs and does not contain;
|
|
72
|
+
5. **a proposed scope cut** — the one thing to drop or defer first;
|
|
73
|
+
6. **VERDICT: READY** or **VERDICT: REVISE**, with the gaps listed.
|
|
74
|
+
|
|
75
|
+
A review note that lacks any of the six parts is incomplete: bounce it to the reviewer with a
|
|
76
|
+
direct message naming the missing part. Never fill a part in on a reviewer's behalf.
|
|
77
|
+
|
|
78
|
+
## 3. One card, one item per reviewer
|
|
79
|
+
|
|
80
|
+
Run the two Scrooge reads first (they are stateless and need no card id), then open the card with
|
|
81
|
+
`relay_task_add`:
|
|
82
|
+
|
|
83
|
+
- title `PRD review: <project>`, phase `PRD`, difficulty `hard`, assigned to you, status `doing`;
|
|
84
|
+
- `checklist`: exactly ONE item per reviewer, labelled with the reviewer's identity
|
|
85
|
+
(`codex:<project>`, `glm:<project>`, `scrooge deepseek/deepseek-v4-flash`, …);
|
|
86
|
+
- the opening `note`: the document path, the frozen roster, the six-part rubric, and the consensus
|
|
87
|
+
rule from §4.
|
|
88
|
+
|
|
89
|
+
Append each Scrooge verdict UNCHANGED as a card note prefixed with its receipt's model, then tick
|
|
90
|
+
that model's item. Carrying a reader's verdict onto the card is transport, not a vote of yours.
|
|
91
|
+
|
|
92
|
+
Then `relay_send` every crew seat the same contract, as a DIRECT message (broadcasts do not wake
|
|
93
|
+
a seat), under 280 characters:
|
|
94
|
+
|
|
95
|
+
> PRD review card #<id>: read docs/PRD.md in your worktree. Note on the card, in order:
|
|
96
|
+
> completeness, ambiguity, feasibility+risk, missing requirements, one proposed scope cut, then
|
|
97
|
+
> VERDICT READY|REVISE with the gaps. Tick your item (index <n>). No file edits.
|
|
98
|
+
|
|
99
|
+
A seat records its review with `relay_task_move` to the card's CURRENT status carrying the note
|
|
100
|
+
(the note is the review; the move is how a note lands), then ticks only its own checklist item
|
|
101
|
+
with `relay_task_check`. Reviews are independent: a seat that quotes another seat's note gets a
|
|
102
|
+
bounce, not a tick.
|
|
103
|
+
|
|
104
|
+
## 4. Consensus, then the operator
|
|
105
|
+
|
|
106
|
+
Supervise as the crew skill's foreman loop: `relay_wait`, sweep the card and `relay_peers`, nudge
|
|
107
|
+
a silent seat by direct message, `trantor up` a dead one and resend its contract. The review is
|
|
108
|
+
in when every checklist item is ticked and every note has the six parts.
|
|
109
|
+
|
|
110
|
+
- **All READY** = the crew's pass.
|
|
111
|
+
- **Any REVISE** = you merge every gap from every reviewer, READY ones included, into ONE
|
|
112
|
+
revision request: deduplicated, attributed, ordered by how many reviewers raised it.
|
|
113
|
+
|
|
114
|
+
Either way the outcome goes to the OPERATOR in ask mode: this gate is always asked, never
|
|
115
|
+
auto-fired. Present the roster, one line per verdict, the merged revision request when there is
|
|
116
|
+
one, and the crew's outcome, then ask (`AskUserQuestion` when the harness offers it, plain chat
|
|
117
|
+
otherwise). The operator may confirm the consensus, override a REVISE into a pass, or send a
|
|
118
|
+
unanimous READY back for revision. Record the decision and its reason as a card note.
|
|
119
|
+
|
|
120
|
+
- **Pass:** move the card to `testing` with the tally (`n READY / m REVISE`, the decision), then
|
|
121
|
+
to `done`. Continue with the TDD phase.
|
|
122
|
+
- **Revise:** hand the operator the revision request; the operator revises `docs/PRD.md` or asks
|
|
123
|
+
you to draft the revision for their approval. When the file changes, untick every item
|
|
124
|
+
(`relay_task_check` with `done: false`), re-dispatch the same roster on the SAME card, and
|
|
125
|
+
repeat §3 and §4.
|
|
126
|
+
|
|
127
|
+
## 5. TDD phase (`/trantor:prd-review tdd` resumes here)
|
|
128
|
+
|
|
129
|
+
The PRD card is done. Now one author writes the design and the same reviewers review it.
|
|
130
|
+
|
|
131
|
+
1. **Author.** Pick ONE live crew seat as the author (the strongest coding seat the profile
|
|
132
|
+
gives you; say which and why on the card). Its contract: write `docs/TDD.md` from the accepted
|
|
133
|
+
PRD, covering architecture, the interfaces and data flow (the event/interface contract between
|
|
134
|
+
agents), file ownership as one file-set per seat, dependencies, the verification plan, risks,
|
|
135
|
+
and a work breakdown of build packages each tagged `easy|medium|hard`. The author does not
|
|
136
|
+
review its own design.
|
|
137
|
+
2. **Card.** Open or reuse exactly one `TDD review: <project>` card in phase `TDD`. Its checklist
|
|
138
|
+
starts with `author <seat>: docs/TDD.md written`, followed by one item per reviewer of the
|
|
139
|
+
frozen roster minus the author. The author ticks its item only once the file exists.
|
|
140
|
+
3. **Rubric, adapted to a design:** completeness (every PRD requirement has a home in the design),
|
|
141
|
+
ambiguity, feasibility and risk, missing design pieces (interfaces, ownership, verification),
|
|
142
|
+
a proposed scope cut, `VERDICT: READY` or `VERDICT: REVISE` with the gaps. Same Scrooge reads
|
|
143
|
+
with `docs/TDD.md` in the prompt, same direct contracts to the seats, same note shape, same
|
|
144
|
+
ticking rule.
|
|
145
|
+
4. **Same consensus, same operator gate** as §4, on the TDD card. A revision goes back to the
|
|
146
|
+
author, not to the operator to write.
|
|
147
|
+
|
|
148
|
+
## 6. On a TDD pass: the build cards open themselves
|
|
149
|
+
|
|
150
|
+
The confirmed TDD is the gate. Do not ask the operator again merely to open its build cards.
|
|
151
|
+
|
|
152
|
+
1. Move the TDD card through `testing` to `done` with the tally and the decision.
|
|
153
|
+
2. `relay_advise` with the work breakdown's packages, then one `relay_task_add` per package:
|
|
154
|
+
phase `build`, the advisor's assignee and `model`, its `difficulty`, `deps` on the packages it
|
|
155
|
+
needs first, and a `checklist` of its acceptance tests from the TDD's verification plan. A
|
|
156
|
+
build card without its model set is a defect.
|
|
157
|
+
3. Start the build as the crew skill's phase 3: contracts over the bus, one file-set per seat.
|
|
158
|
+
How far the build may go on its own (commit, push, deploy, handing off) is the project's
|
|
159
|
+
autonomy dial (`trantor autonomy`); the dial governs the build, not the opening of its cards.
|