trantor 0.18.30 → 0.18.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.28",
3
+ "version": "0.18.32",
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/bin/cli.mjs CHANGED
@@ -209,7 +209,7 @@ switch (cmd) {
209
209
  trantor gates verification gates: "must verify before shipping" claims that survive handoffs — [--all] [--json]
210
210
  trantor backfill card past GIT work onto the board (solo commits that were never carded) — [--since "14 days ago"] [--dry-run]
211
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)
212
+ trantor new project genesis: new <name> [--from <git-url>] [--brief <file>] [--dir <parent>] [--adopt] [--json] — creates <parent>/<name>, git main, CLAUDE.md from the brief, hooks, hub brief + first card (never spawns a session)
213
213
  trantor balances how much credit is left on each CONFIGURED provider (from your profile) — refill before you stall — [--json]
214
214
  trantor recost recompute sub-agent notional cost from on-disk transcripts + reseed the board (repair after upgrade) — [--dry-run]
215
215
  trantor handoff finish this session NOW: write a handoff, open a fresh session that takes over, and close this one (manual baton)
@@ -19,6 +19,7 @@ import { ensureEnrolled } from "../lib/enroll.mjs";
19
19
  import { redactKeys } from "../lib/redact.mjs";
20
20
  import {
21
21
  AUTH_MARKER_RE, classifyFailure, looksLikeAuthDeath,
22
+ verdictFor,
22
23
  readPromptText, stripPromptEcho,
23
24
  } from "../lib/classify-failure.mjs";
24
25
  import { capWake, capBcast, pickLessons, composePrompt } from "./crew-payload.mjs";
@@ -381,13 +382,13 @@ async function reportFailure(exit, trigger, undelivered = 0) {
381
382
  const state = `${down ? "down" : "error"}:${reason}`;
382
383
  if (state !== announced) {
383
384
  announced = state;
384
- await api("/send", { from: SESSION, to: "all", text, project: PROJ }).catch(() => {});
385
+ await api("/send", { from: SESSION, to: "all", text, project: PROJ, kind: "status" }).catch(() => {});
385
386
  // #5684: a broadcast does not wake anyone — the incident is the operator spotting dead seats
386
387
  // before the foreman did, twice in one morning. The same state-change event now goes DIRECT
387
388
  // to the project's orchestrator (direct = wake), gated identically so a standing outage says
388
389
  // it once. A seat that IS the orchestrator's own runner has nobody above it to wake.
389
390
  const orch = `${hostId()}:${PROJ}`;
390
- if (orch !== SESSION) await api("/send", { from: SESSION, to: orch, text, project: PROJ }).catch(() => {});
391
+ if (orch !== SESSION) await api("/send", { from: SESSION, to: orch, text, project: PROJ, kind: "alert" }).catch(() => {});
391
392
  } else {
392
393
  log(`still ${state} (${consecFails} fails) — already announced, staying quiet`);
393
394
  }
@@ -433,7 +434,7 @@ async function notifyAssigners(pairs, text) {
433
434
  seen.add(f);
434
435
  // `re` threads this outcome to the exact contract it answers, so the sender's ledger closes the
435
436
  // right one instead of guessing from timing.
436
- const payload = { from: SESSION, to: f, text: text.slice(0, 280), project: PROJ };
437
+ const payload = { from: SESSION, to: f, text: text.slice(0, 280), project: PROJ, kind: "receipt" };
437
438
  if (id) payload.re = id;
438
439
  await api("/send", payload).catch(() => {});
439
440
  }
@@ -446,7 +447,7 @@ async function reportHealthy() {
446
447
  // Recovery is a change too, so the next failure is news again.
447
448
  announced = "";
448
449
  await api("/register", { session: SESSION, project: PROJ, status: `active in ${PROJ}`, llm: AGENT, model: MODEL }).catch(() => {});
449
- await api("/send", { from: SESSION, to: "all", text: `✅ ${SESSION} recovered`, project: PROJ }).catch(() => {});
450
+ await api("/send", { from: SESSION, to: "all", text: `✅ ${SESSION} recovered`, project: PROJ, kind: "status" }).catch(() => {});
450
451
  cmuxStatus("ok", "#14b8a6", "check"); herdrAgent("idle");
451
452
  }
452
453
 
@@ -461,6 +462,10 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
461
462
  const pf = join(homedir(), ".agent-bus", `turn-${AGENT}-${PROJ}.txt`);
462
463
  appendFileSync(pf, "", { flag: "w" }); // truncate
463
464
  appendFileSync(pf, prompt);
465
+ // #5868: where HEAD stood when the turn began. A turn that moved it shipped real work, and an
466
+ // exit-0 turn with real output must never be re-labelled "auth" by the #5405 escalation — the
467
+ // qwen specimen committed aa3c340 while its captured stream still tripped the auth regex.
468
+ const headBefore = gitOut(["rev-parse", "HEAD"], TURN_DIR);
464
469
  let cmd = (isFirst || (cli.sid && !sid)) ? cli.first : cli.next;
465
470
  const mfrag = MODEL && cli.mflag ? `${cli.mflag}${MODEL}` : "";
466
471
  cmd = cmd.replaceAll("{M}", mfrag).replaceAll("{P}", pf).replaceAll("{SID}", sid);
@@ -543,10 +548,15 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
543
548
  // fail the turn. Telemetry keeps the REAL exit; the returned code is the effective one every
544
549
  // call site branches on (kickoff, pulse, deliverWake).
545
550
  let effExit = realExit;
546
- if (realExit === 0 && looksLikeAuthDeath(ownOut)) {
551
+ let authHit = "";
552
+ // #5868: a NEW commit since turn start is real work, and an exit-0 turn with real output is
553
+ // never re-labelled auth — the qwen specimen exited 0 with a shipped commit (aa3c340) while a
554
+ // short capture of echoed contract text tripped the regex.
555
+ const newCommit = !!headBefore && gitOut(["rev-parse", "HEAD"], TURN_DIR) !== headBefore;
556
+ if (realExit === 0 && looksLikeAuthDeath(ownOut, newCommit)) {
547
557
  effExit = 1;
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`);
558
+ authHit = AUTH_MARKER_RE.exec(ownOut)[0];
559
+ log(`\x1b[31mexit 0 but the turn output IS an auth failure — treating as FAILED (auth, "${authHit}")\x1b[0m`);
550
560
  }
551
561
  // #5481: the Inception/Mercury trap — exit 0 with a NULL completion. ERRF is the TOTAL output
552
562
  // capture, not just stderr: every seat's stdout is tee'd into it (`| tee -a ERRF` for the
@@ -562,7 +572,10 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
562
572
  lastEmptyOutput = true;
563
573
  log("\x1b[31mexit 0 but the turn produced NO output — treating as FAILED (empty-output)\x1b[0m");
564
574
  }
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 });
575
+ // #5868: the verdict rides the telemetry row so a classification survives the pane scrolling
576
+ // away — the same "classified X because Y" shape the runner logs, in the seat's jsonl forever.
577
+ const verdict = verdictFor(realExit, effExit, lastEmptyOutput, ownOut);
578
+ 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 });
566
579
  log(`turn ended (exit ${realExit}${effExit !== realExit ? ` → effective ${effExit} (${lastEmptyOutput ? "empty-output" : "auth"})` : ""}, ${((Date.now() - t0) / 1000).toFixed(0)}s)`);
567
580
  if (realExit === 0 && effExit === 0) { cmuxStatus("idle", "#8a94a6", "robot"); herdrAgent("idle"); } // finished this turn, waiting for the next
568
581
  // #5965 — TURN END. A clean exit means the seat is idle again; say so right away so the app stops
@@ -604,6 +617,57 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
604
617
  return built.prompt;
605
618
  }
606
619
 
620
+ const RECEIPT_MARKER = "✅ done on";
621
+ const CARD_REF_RE = /#\d{1,7}(?!\d)/;
622
+
623
+ // Runner-authored metadata is bus state, not work. Typed messages are authoritative; `re` and the
624
+ // stable text marker keep a mixed-version crew safe while older runners are still on the bus.
625
+ function isReceipt(message) {
626
+ const text = String(message?.text || "").trimStart();
627
+ return message?.kind === "receipt" || Number(message?.re) > 0 || text.startsWith(RECEIPT_MARKER);
628
+ }
629
+
630
+ function isStatusBroadcast(message) {
631
+ if (message?.to !== "all") return false;
632
+ if (message?.kind === "status") return true;
633
+ const text = String(message?.text || "").trim();
634
+ return /^[A-Za-z0-9_.-]+ reporting — ready for a contract\b/.test(text)
635
+ || /^[✅⚠️🛑]\s+\S+\s+(?:recovered|turn FAILED|DOWN)\b/.test(text);
636
+ }
637
+
638
+ function isContract(message) {
639
+ const text = String(message?.text || "");
640
+ return message?.kind === "contract" || /^\s*contract\s*:/i.test(text) || CARD_REF_RE.test(text);
641
+ }
642
+
643
+ function isRunnerSession(session) {
644
+ const suffix = `:${PROJ}`;
645
+ const name = String(session || "");
646
+ if (!name.endsWith(suffix)) return false;
647
+ const label = name.slice(0, -suffix.length);
648
+ // Crew labels are CLI/provider slugs. Host sessions keep their machine-style identity and remain
649
+ // valid direct assigners; runner-to-runner prose needs `contract:` or a card reference.
650
+ return /^[a-z0-9_.-]+$/.test(label) && !label.startsWith("hub:");
651
+ }
652
+
653
+ function shouldWake(message) {
654
+ if (isReceipt(message) || isStatusBroadcast(message)) return false;
655
+ if (message?.to === SESSION) {
656
+ if (message?.kind === "status") return false;
657
+ return !isRunnerSession(message?.from) || isContract(message);
658
+ }
659
+ return message?.to === "all"
660
+ && isContract(message)
661
+ && (message.text.includes(`@${AGENT}`) || message.text.toLowerCase().includes(`${AGENT}:`));
662
+ }
663
+
664
+ function askedExcerpt(message) {
665
+ let text = String(message?.text || "").replace(/\s+/g, " ").trim();
666
+ const nested = text.search(/\s+[·|]\s*asked\s*:/i);
667
+ if (nested >= 0) text = text.slice(0, nested).trim();
668
+ return text.slice(0, 120);
669
+ }
670
+
607
671
  (async () => {
608
672
  await loadLessons();
609
673
  // start cursor at the CURRENT tip so we don't replay history
@@ -618,7 +682,7 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
618
682
  const { loadOrCreate } = await import("../lib/identity.mjs");
619
683
  await sfetchJson(`${HUB}/send`, {
620
684
  identity: loadOrCreate(SESSION, "agent"),
621
- payload: { from: SESSION, to: "all", project: PROJ, text: `${AGENT} reporting — ready for a contract${MODEL ? ` (${MODEL})` : ""}` },
685
+ payload: { from: SESSION, to: "all", project: PROJ, kind: "status", text: `${AGENT} reporting — ready for a contract${MODEL ? ` (${MODEL})` : ""}` },
622
686
  signal: AbortSignal.timeout(2500),
623
687
  });
624
688
  } catch {}
@@ -627,8 +691,8 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
627
691
  // broadcasts batched behind them. Restored from disk first: a runner that was killed mid-turn
628
692
  // (or a machine that rebooted) still owes those messages, and the hub will never send them again.
629
693
  const restored = loadPending();
630
- let pendingWake = restored.wake;
631
- let pendingBcast = restored.bcast;
694
+ let pendingWake = restored.wake.filter(shouldWake);
695
+ let pendingBcast = restored.bcast.filter(m => !isReceipt(m) && !isStatusBroadcast(m));
632
696
  let retryAt = 0; // 0 = deliver at the next opportunity
633
697
  let deliveryFails = 0; // consecutive failed attempts at the SAME pending batch
634
698
  if (pendingWake.length) log(`\x1b[33m${pendingWake.length} message(s) survived from a previous run — redelivering\x1b[0m`);
@@ -675,6 +739,10 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
675
739
  // never wake on your own broadcasts: a claude seat's report contains "claude:" and matched the
676
740
  // @mention filter, buying one echo turn per report (seen live on the first pulsed orchestrator)
677
741
  msgs = msgs.filter(m => m.from !== SESSION);
742
+ // A receipt is the terminal state of a contract, never a new contract. Consume typed receipts,
743
+ // reply-linked outcomes, and the old stable marker before direct-address logic sees them. Status
744
+ // broadcasts are presence chatter and are dropped rather than saved as future prompt context.
745
+ msgs = msgs.filter(m => !isReceipt(m) && !isStatusBroadcast(m));
678
746
  // #5760 (the night of 08-31): the hub's hourly "same-project-sessions" FYI woke every seat
679
747
  // into a real CLI turn — three wedged for hours mid-chatter, one on the metered pool. That
680
748
  // kind is pure coordination CONTEXT ("no human needs to relay this" — and no turn needs to
@@ -682,8 +750,8 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
682
750
  // warnings still wake — those are actionable by the seat right now.
683
751
  const fyi = msgs.filter(m => m.from === "hub:duty" && String(m.text || "").startsWith("🤝 OVERSEER same-project-sessions"));
684
752
  const rest = msgs.filter(m => !fyi.includes(m));
685
- const direct = rest.filter(m => m.to === SESSION);
686
- const mentions = rest.filter(m => m.to === "all" && (m.text.includes(`@${AGENT}`) || m.text.toLowerCase().includes(`${AGENT}:`)));
753
+ const direct = rest.filter(m => m.to === SESSION && shouldWake(m));
754
+ const mentions = rest.filter(m => m.to === "all" && shouldWake(m));
687
755
  const bcast = [...rest.filter(m => m.to === "all" && !mentions.includes(m)), ...fyi];
688
756
  pendingBcast.push(...bcast); // wake-policy: plain broadcasts batch, they don't wake
689
757
  const wake = [...direct, ...mentions];
@@ -695,7 +763,7 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
695
763
  const dropped = pendingWake.splice(0, pendingWake.length - PENDING_MAX);
696
764
  log(`\x1b[31mundelivered queue overflowed — dropped ${dropped.length} oldest message(s)\x1b[0m`);
697
765
  await api("/send", { from: SESSION, to: "all", project: PROJ,
698
- text: `⚠️ ${SESSION} dropped ${dropped.length} undelivered message(s) — queue hit its ${PENDING_MAX} cap during a failure streak` }).catch(() => {});
766
+ kind: "status", text: `⚠️ ${SESSION} dropped ${dropped.length} undelivered message(s) — queue hit its ${PENDING_MAX} cap during a failure streak` }).catch(() => {});
699
767
  }
700
768
  savePending(pendingWake, pendingBcast);
701
769
  // Respect an active backoff: a new message during an outage joins the batch, it does not
@@ -728,7 +796,7 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
728
796
  // Who is owed an answer, captured BEFORE the turn: pendingWake is cleared on success.
729
797
  const assigners = [];
730
798
  for (const m of wake) if (m.from && !assigners.some(a => a.from === m.from)) assigners.push({ from: m.from, id: m.id });
731
- const asked = String(wake[0]?.text || "").replace(/\s+/g, " ").trim().slice(0, 90);
799
+ const asked = askedExcerpt(wake[0]);
732
800
  const tStart = Date.now();
733
801
  const prompt = composedTurn({
734
802
  wakeText, ctxText, againText,
package/bin/crew.sh CHANGED
@@ -352,7 +352,7 @@ spawn_herdr() { # $@ = specs
352
352
  local surfs=()
353
353
  [ -n "$REUSE_WS" ] && wsid="$REUSE_WS"
354
354
  for SPEC in "$@"; do
355
- resolve_spec "$SPEC"
355
+ resolve_spec "$SPEC" || continue
356
356
  local cmd; cmd="$(RUN_CMD)"
357
357
  if [ -n "$REUSE_WS" ]; then
358
358
  # replace-in-place: split the fresh pane FIRST (targeting the agent's old pane when tracked),
@@ -733,7 +733,8 @@ echo "[crew] hub for $PROJ: $HUB_URL (baked into every seat; CREW_HUB=<url> over
733
733
  SCROOGE="$BUS_DIR/engine/bin/scrooge"
734
734
  [ -f "$SCROOGE" ] || SCROOGE="$(command -v scrooge 2>/dev/null || echo scrooge)"
735
735
 
736
- # resolve_model <agent> <provider> <task> <diff> -> echoes a runner-ready model id, or empty (→ CLI default).
736
+ # resolve_model <agent> <provider> <task> <diff> -> echoes a provider-qualified model id.
737
+ # A provider seat must never fall through to opencode's unrelated global default.
737
738
  resolve_model() {
738
739
  local agent="$1" provider="$2" task="$3" diff="$4" cands="" out=""
739
740
  cands="$(opencode models "$provider" 2>/dev/null | tr '\n' ' ')"
@@ -742,16 +743,34 @@ resolve_model() {
742
743
  else
743
744
  out="$(python3 "$SCROOGE" route --provider "$provider" -t "$task" -d "$diff" --json 2>/dev/null)"
744
745
  fi
745
- [ -n "$out" ] || { echo "[crew] live model selection failed for $agent:$provider — CLI default" >&2; return 0; }
746
- printf '%s' "$out" | python3 -c 'import json,sys
746
+ if [ -n "$out" ]; then
747
+ out="$(printf '%s' "$out" | python3 -c 'import json,sys
747
748
  try: print(json.load(sys.stdin).get("qualified") or "")
748
- except Exception: pass' 2>/dev/null
749
+ except Exception: pass' 2>/dev/null)"
750
+ fi
751
+ # The router can be absent (a fresh machine, a dry run, scrooge without its registry). That must
752
+ # never hand the seat to opencode's GLOBAL default (#6068, the DeepSeek bill) and must not drop
753
+ # the seat either (#6110): fall back INSIDE the provider — the head of its own catalog — and say so.
754
+ if [ -z "$out" ] && [ -n "$cands" ]; then
755
+ out="$provider/${cands%% *}"
756
+ echo "[crew] router unavailable for $agent:$provider — using the provider's own catalog head ($out)" >&2
757
+ fi
758
+ [ -n "$out" ] || { echo "[crew] live model selection failed for $agent:$provider — no router and no catalog; refusing opencode global default" >&2; return 1; }
759
+ [ "${out%%/*}" = "$provider" ] || {
760
+ echo "[crew] router selected $out outside $provider — refusing cross-provider fallback" >&2
761
+ return 1
762
+ }
763
+ printf '%s' "$out"
749
764
  }
750
765
 
751
766
  epoch_ms() { python3 -c 'import time;print(int(time.time()*1000))'; }
752
767
 
753
768
  # resolve_spec <spec> -> sets AGENT + MODEL globals (live-selects a provider-only spec).
754
- AGENT=""; MODEL=""
769
+ # On failure, returns 1 (AGENT is still set; MODEL is empty) instead of exiting the whole script —
770
+ # callers are responsible for skipping that one seat and continuing the batch. SKIPPED_SEATS
771
+ # accumulates "agent: reason" entries across every spawn path so `up` can report + exit non-zero
772
+ # at the end without killing seats that already launched earlier in the loop.
773
+ AGENT=""; MODEL=""; SKIPPED_SEATS=()
755
774
  resolve_spec() {
756
775
  local SPEC="$1" FIELD
757
776
  AGENT="${SPEC%%:*}"; MODEL=""
@@ -760,16 +779,39 @@ resolve_spec() {
760
779
  # into the launcher string. AGENT is set above and DIR is fixed, so this is the earliest safe point.
761
780
  reap_seat
762
781
  FIELD=""; [ "$SPEC" != "$AGENT" ] && FIELD="${SPEC#*:}"
763
- [ "$AGENT" = "openrouter" ] && [ -z "$FIELD" ] && FIELD="openrouter"
782
+ # Bare native seats use their own CLI defaults. Bare opencode-hosted seats MUST name their
783
+ # provider implicitly: glm is the one non-obvious alias; every discovered/BYOM seat's label is
784
+ # its provider id. Leaving FIELD empty is what handed qwen/glm to opencode's global DeepSeek.
785
+ if [ -z "$FIELD" ]; then
786
+ case "$AGENT" in
787
+ codex|kimi|claude|gemini|dsh|opencode) ;;
788
+ glm) FIELD="zai-coding-plan" ;;
789
+ *) FIELD="$AGENT" ;;
790
+ esac
791
+ fi
764
792
  if [ -n "$FIELD" ]; then
765
793
  case "$FIELD" in
766
794
  */*) MODEL="$FIELD" ;;
767
- *) MODEL="$(resolve_model "$AGENT" "$FIELD" "$TASK" "$DIFF")"
768
- if [ -n "$MODEL" ]; then echo " → $AGENT: live model $MODEL ($FIELD · $TASK/$DIFF)"
769
- else echo "$AGENT: '$FIELD' live selection unavailable CLI default"; fi ;;
795
+ *) MODEL="$(resolve_model "$AGENT" "$FIELD" "$TASK" "$DIFF")" || {
796
+ echo "[crew] skipping seat '$AGENT' model resolution failed for $FIELD ($TASK/$DIFF); remaining seats still launch" >&2
797
+ SKIPPED_SEATS+=("$AGENT: model resolution failed for $FIELD ($TASK/$DIFF)")
798
+ return 1
799
+ }
800
+ echo " → $AGENT: live model $MODEL ($FIELD · $TASK/$DIFF)" ;;
770
801
  esac
771
802
  fi
772
803
  }
804
+ # report_skipped_seats: prints a summary of every seat resolve_spec skipped this run (if any) and
805
+ # returns 1 so callers can propagate a non-zero exit — the whole point is that a batch with some
806
+ # skips still launched the rest of the crew, so this is a REPORT, not an abort.
807
+ report_skipped_seats() {
808
+ [ "${#SKIPPED_SEATS[@]}" -gt 0 ] || return 0
809
+ echo ""
810
+ echo "✗✗ ${#SKIPPED_SEATS[@]} seat(s) skipped (model resolution failed) — the rest of the crew still launched:"
811
+ local s
812
+ for s in "${SKIPPED_SEATS[@]}"; do echo " - $s"; done
813
+ return 1
814
+ }
773
815
  # Kill any runner ALREADY serving this exact agent+project before starting another.
774
816
  #
775
817
  # Without this, `trantor up <agent>` ADDS a runner instead of replacing one — and every duplicate
@@ -801,7 +843,7 @@ spawn_tmux() { # $@ = specs
801
843
  # a pre-existing session for THIS project = the crew is already up; add missing seats as new panes.
802
844
  tmux has-session -t "$TMUX_SESS" 2>/dev/null && first=0
803
845
  for SPEC in "$@"; do
804
- resolve_spec "$SPEC"
846
+ resolve_spec "$SPEC" || continue
805
847
  local cmd; cmd="$(RUN_CMD)"
806
848
  local pane=""
807
849
  if [ "$first" = "1" ]; then
@@ -852,7 +894,7 @@ spawn_grid() { # $@ = specs
852
894
  local ROWS=$(( (N + COLS - 1) / COLS ))
853
895
  local CW=$(( GW / COLS )) CH=$(( GH / ROWS )) i=0 SPEC
854
896
  for SPEC in "$@"; do
855
- resolve_spec "$SPEC"
897
+ resolve_spec "$SPEC" || continue
856
898
  local cmd; cmd="$(RUN_CMD)"
857
899
  local C=$(( i % COLS )) R=$(( i / COLS )) X1 Y1 WID=""
858
900
  X1=$(( GX + C * CW )); Y1=$(( GY + R * CH ))
@@ -937,7 +979,7 @@ spawn_cmux() { # $@ = specs
937
979
  local surfs=()
938
980
  [ -n "$REUSE_WS" ] && wsid="$REUSE_WS"
939
981
  for SPEC in "$@"; do
940
- resolve_spec "$SPEC"
982
+ resolve_spec "$SPEC" || continue
941
983
  local cmd launcher; cmd="$(RUN_CMD)"; launcher="$(_seat_launcher "$AGENT" "$cmd")"
942
984
  if [ -n "$REUSE_WS" ]; then
943
985
  # replace-in-place: split the fresh pane FIRST (targeting the agent's old pane when tracked),
@@ -1039,7 +1081,7 @@ OSA
1039
1081
  fi
1040
1082
  [ -n "$REUSE_TAB" ] && { tabid="$REUSE_TAB"; echo " → reusing existing crew workspace for $PROJ ($tabid)"; }
1041
1083
  for SPEC in "$@"; do
1042
- resolve_spec "$SPEC"
1084
+ resolve_spec "$SPEC" || continue
1043
1085
  local cmd launcher; cmd="$(RUN_CMD)"; launcher="$(_seat_launcher "$AGENT" "$cmd")"
1044
1086
  # In REUSE mode, replace-in-place: split off the agent's old terminal when tracked, close it after.
1045
1087
  local OLD_SURF=""
@@ -1160,7 +1202,11 @@ echo "— bringing up crew for $PROJ ($CREW_UI) —"
1160
1202
  SPAWN_EPOCH=$(epoch_ms)
1161
1203
  spawn_crew "$@"
1162
1204
 
1163
- if [ "$DRY" = "1" ]; then echo "— dry run: no bus verify —"; exit 0; fi
1205
+ if [ "$DRY" = "1" ]; then
1206
+ echo "— dry run: no bus verify —"
1207
+ report_skipped_seats
1208
+ exit $?
1209
+ fi
1164
1210
  echo "— verifying on the bus (the spawn is not the truth; the bus is) —"
1165
1211
  AGENTS_ONLY=$(for a in "$@"; do printf "%s " "${a%%:*}"; done)
1166
1212
  VER=$(node "$BUS_DIR/bin/crew-verify.mjs" "$PROJ" $AGENTS_ONLY --since "$SPAWN_EPOCH" --timeout 30)
@@ -1181,3 +1227,5 @@ if [ -n "${RETRY// }" ]; then
1181
1227
  fi
1182
1228
  fi
1183
1229
  echo "— crew verified on the bus. Send contracts with relay_send; runners keep agents alive for free. Teardown (this project only): trantor down —"
1230
+ report_skipped_seats
1231
+ exit $?
package/bin/new.mjs CHANGED
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  // trantor new — project genesis, the CLI half (#5862). One command stands a project up:
3
3
  //
4
- // trantor new <name> [--from <git-url>] [--brief <file>] [--dir <path>] [--adopt] [--json]
4
+ // trantor new <name> [--from <git-url>] [--brief <file>] [--dir <parent>] [--adopt] [--json]
5
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
6
+ // It makes the project directory at <parent>/<name> --dir names the PARENT, never the project
7
+ // directory itself (default parent: TRANTOR_DEV_ROOT or ~/development). The name is always
8
+ // appended under it, so `--dir P` with name N creates P/N. Starts git on main (or clones --from,
9
+ // or adopts an existing folder with --adopt), seeds CLAUDE.md from the
8
10
  // brief (verbatim brief + the trantor conventions block), installs the same auto-card hook as
9
11
  // `trantor init-hooks`, posts the brief as the hub project brief (POST /project — the same call
10
12
  // relay_project_brief makes), and opens the first card "genesis: <name>" on the new board.
@@ -17,7 +19,8 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, append
17
19
  import { homedir } from "node:os";
18
20
  import { dirname, isAbsolute, join, resolve } from "node:path";
19
21
  import { fileURLToPath } from "node:url";
20
- import { ensureEnrolled, loadIdentity, signedPost } from "../hooks/lib/api.mjs";
22
+ import { ensureEnrolled as enrollTofu, loadIdentity, signedPost } from "../hooks/lib/api.mjs";
23
+ import { ensureEnrolled as enrollViaOwnerInvite } from "../lib/enroll.mjs";
21
24
  import { setAutonomy } from "../lib/autonomy.mjs";
22
25
  import { resolveHub, setProjectHub } from "../lib/project.mjs";
23
26
 
@@ -52,7 +55,7 @@ const flag = (n) => { const i = args.indexOf("--" + n); return i >= 0 ? args[i +
52
55
  const has = (n) => args.includes("--" + n);
53
56
  const json = has("json");
54
57
  const name = args.find(a => !a.startsWith("--"));
55
- if (!name) die("usage: trantor new <name> [--from <git-url>] [--brief <file>] [--dir <path>] [--adopt] [--json]");
58
+ if (!name) die("usage: trantor new <name> [--from <git-url>] [--brief <file>] [--dir <parent>] [--adopt] [--json] — project lands at <parent>/<name>");
56
59
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) die(`invalid project name "${name}" — letters, digits, dot, dash, underscore`);
57
60
  const from = flag("from");
58
61
  const briefFile = flag("brief");
@@ -120,10 +123,18 @@ const identity = loadIdentity(session);
120
123
  let card = null;
121
124
  let hubError = null;
122
125
  try {
123
- await ensureEnrolled(session, identity, name);
126
+ // The genesis identity is BRAND NEW — it cannot write to a project on an enforce hub until the
127
+ // hub knows it. TOFU /enroll only works on a local loopback hub; a remote enforce hub refuses it
128
+ // (403 "tofu enrollment refused") and the genesis silently records nothing (#6049). So enroll the
129
+ // way crew seats do: the operator's owner key mints a project-scoped write invite and the genesis
130
+ // identity spends it. Only when NO owner key is configured (a loopback hub with no owner identity)
131
+ // do we fall back to the plain TOFU enroll.
132
+ const viaOwner = await enrollViaOwnerInvite(hub, identity, name, { timeoutMs: 8000 });
133
+ if (!viaOwner.ok && viaOwner.reason === "no-owner-key") await enrollTofu(session, identity, name);
134
+ else if (!viaOwner.ok) console.error(`genesis: enrollment via owner invite failed: ${viaOwner.reason}`);
124
135
  const briefForHub = (brief || `Genesis of ${name} — created by trantor new.`).slice(0, 600);
125
136
  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`);
137
+ if (!r1.ok) throw new Error(`hub ${r1.status} on /project${r1.json?.error ? `: ${r1.json.error}` : ""}`);
127
138
  const r2 = await signedPost("/task", {
128
139
  project: name,
129
140
  title: `genesis: ${name}`,
@@ -131,7 +142,7 @@ try {
131
142
  by: session,
132
143
  note: "project genesis — created by trantor new",
133
144
  }, { session, project: name, timeoutMs: 8000 });
134
- if (!r2.ok) throw new Error(`hub ${r2.status} on /task`);
145
+ if (!r2.ok) throw new Error(`hub ${r2.status} on /task${r2.json?.error ? `: ${r2.json.error}` : ""}`);
135
146
  card = r2.json?.task?.id ?? null;
136
147
  } catch (e) {
137
148
  hubError = e instanceof Error ? e.message : String(e);
@@ -139,8 +150,10 @@ try {
139
150
  }
140
151
 
141
152
  // ── report ──────────────────────────────────────────────────────────────────────────────────────
153
+ // dir is the created project directory <parent>/<name>; parent is the --dir (or default) root the
154
+ // name was appended under — the two together state the parent contract explicitly.
142
155
  if (json) {
143
- console.log(JSON.stringify({ name, dir, branch, hub, card }));
156
+ console.log(JSON.stringify({ name, parent: devRoot, dir, branch, hub, card }));
144
157
  } else {
145
158
  console.log(`✓ ${dir} (${branch}${from ? ", cloned" : adopt ? ", adopted" : ""})`);
146
159
  console.log(`✓ CLAUDE.md seeded${brief ? " from the brief" : " (no brief — add the project's what/why/goal)"}`);
package/bin/patrol.mjs CHANGED
@@ -43,6 +43,11 @@ function rowMatchesRunner(row, runner) {
43
43
  return !rp || rp === runnerProject(runner);
44
44
  }
45
45
 
46
+ function seatProvider(agent) {
47
+ if (["codex", "kimi", "claude", "gemini", "dsh", "opencode"].includes(agent)) return null;
48
+ return agent === "glm" ? "zai-coding-plan" : agent;
49
+ }
50
+
46
51
  function sortedProjects(projects) {
47
52
  return [...projects].sort((a, b) => displayProject(a).localeCompare(displayProject(b)));
48
53
  }
@@ -76,6 +81,7 @@ export function buildPatrolReport(rawInventory = {}, reaped = [], { bus = busDir
76
81
  const workspaceIds = new Set(workspaces.map(w => String(w?.id || "")).filter(Boolean));
77
82
  const orphans = [];
78
83
  const ambiguous = [];
84
+ const warnings = [];
79
85
 
80
86
  for (const runner of runners) {
81
87
  if (isBusInternalRunner(runner, bus)) continue;
@@ -85,6 +91,18 @@ export function buildPatrolReport(rawInventory = {}, reaped = [], { bus = busDir
85
91
  } else if (!rows.some(row => rowMatchesRunner(row, runner))) {
86
92
  orphans.push({ type: "live-runner-without-row", project: p, agent: runner.agent, pid: runner.pid, dir: runner.dir });
87
93
  }
94
+ const expectedProvider = seatProvider(String(runner?.agent || ""));
95
+ const actualProvider = String(runner?.model || "").split("/")[0];
96
+ if (expectedProvider && actualProvider && actualProvider !== expectedProvider) {
97
+ warnings.push({
98
+ type: "seat-model-provider-mismatch",
99
+ project: p,
100
+ agent: runner.agent,
101
+ model: runner.model,
102
+ expectedProvider,
103
+ pid: runner.pid,
104
+ });
105
+ }
88
106
  }
89
107
 
90
108
  for (const ws of workspaces) {
@@ -124,7 +142,7 @@ export function buildPatrolReport(rawInventory = {}, reaped = [], { bus = busDir
124
142
  };
125
143
  }
126
144
 
127
- return { projects: out, orphans, ambiguous, reaped };
145
+ return { projects: out, orphans, ambiguous, warnings, reaped };
128
146
  }
129
147
 
130
148
  function oldEnough(path, now, maxAgeMs) {
@@ -196,6 +214,10 @@ export function formatHuman(report) {
196
214
  for (const item of report.orphans) lines.push(` - ${item.type}: ${displayProject(item.project)} ${item.agent || item.title || item.handle || item.id || ""}`.trimEnd());
197
215
  lines.push(`ambiguous: ${report.ambiguous.length}`);
198
216
  for (const item of report.ambiguous) lines.push(` - ${item.type}: ${item.agent || item.title || item.dir || item.id || ""}`.trimEnd());
217
+ lines.push(`warnings: ${report.warnings?.length || 0}`);
218
+ for (const item of report.warnings || []) {
219
+ lines.push(` - ${item.type}: ${displayProject(item.project)} ${item.agent} runs ${item.model}; expected ${item.expectedProvider}/*`);
220
+ }
199
221
  lines.push(`reaped: ${report.reaped.length}`);
200
222
  for (const item of report.reaped) lines.push(` - ${item.type}: ${item.path || item.output || ""}`.trimEnd());
201
223
  return `${lines.join("\n")}\n`;
@@ -22,6 +22,16 @@ const TIMEOUT = 2000; // contract: ev
22
22
 
23
23
  const busDir = () => process.env.RELAY_DATA_DIR || join(homedir(), ".agent-bus");
24
24
 
25
+ function opencodeDefaultModel() {
26
+ try {
27
+ const home = process.env.HOME || homedir();
28
+ const configDir = process.env.XDG_CONFIG_HOME || join(home, ".config");
29
+ const config = JSON.parse(readFileSync(join(configDir, "opencode", "opencode.json"), "utf8"));
30
+ const model = config?.model;
31
+ return /^[^\s/]+\/[^\s/]+$/.test(model) ? String(model) : "";
32
+ } catch { return ""; }
33
+ }
34
+
25
35
  // Run a subprocess, return stdout; "" on ANY failure (missing binary, nonzero exit, timeout).
26
36
  function run(cmd, args, env = {}) {
27
37
  try {
@@ -75,7 +85,7 @@ function psTable() {
75
85
  return rows;
76
86
  }
77
87
 
78
- // Live crew-runner processes → [{pid,agent,dir}]. Runner argv (crew.sh RUN_CMD) is
88
+ // Live crew-runner processes → [{pid,agent,dir,model}]. Runner argv (crew.sh RUN_CMD) is
79
89
  // `node …/crew-runner.mjs <agent> <dir>` — dir is the LAST argument, so the regex is anchored
80
90
  // on end-of-string. project=null → all runners; project given → only runners whose dir resolves
81
91
  // to that project. Resolution is the lib/project.mjs walk (git-root basename, else dir basename)
@@ -84,6 +94,7 @@ function psTable() {
84
94
  export function liveRunners(project = null) {
85
95
  try {
86
96
  const out = [];
97
+ const globalModel = opencodeDefaultModel();
87
98
  for (const { pid, cmd } of psTable()) {
88
99
  const m = cmd.match(/crew-runner\.mjs\s+(\S+)\s+(\S+)\s*$/);
89
100
  if (!m) continue;
@@ -92,7 +103,13 @@ export function liveRunners(project = null) {
92
103
  const name = basename(gitRoot(dir) || dir);
93
104
  if (name !== project) continue;
94
105
  }
95
- out.push({ pid, agent, dir });
106
+ // CREW_MODEL is fixed for the runner lifetime. Read only that named environment field —
107
+ // never return the rest of `ps eww`, which can contain provider credentials.
108
+ const envLine = run("ps", ["eww", "-p", String(pid), "-o", "command="]);
109
+ const pinnedModel = (envLine.match(/(?:^|\s)CREW_MODEL=([^\s]*)/) || [])[1] || "";
110
+ const model = pinnedModel || globalModel;
111
+ const modelSource = pinnedModel ? "crew" : (globalModel ? "opencode-global" : "");
112
+ out.push({ pid, agent, dir, model, modelSource });
96
113
  }
97
114
  return out;
98
115
  } catch { return []; }
package/hub.mjs CHANGED
@@ -2627,7 +2627,8 @@ const server = http.createServer(async (req, res) => {
2627
2627
  // is guesswork: a seat still working and a seat that died look identical from the sender's
2628
2628
  // side, which is how an orchestrator ends up waiting forever on a dead peer.
2629
2629
  const re = Number.isFinite(Number(b.re)) && Number(b.re) > 0 ? Number(b.re) : 0;
2630
- 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 } : {}) };
2630
+ const kind = String(b.kind || "").slice(0, 40);
2631
+ 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 } : {}) };
2631
2632
  state.messages.push(msg); if (state.messages.length > 5000) state.messages.splice(0, 1000);
2632
2633
  dirty = true; pushToStreams(msg); // <-- instant push to live watchers
2633
2634
  // Mirror onto the unified log. `refs` = the card ids this message cites (#3701), which is what
@@ -8,10 +8,18 @@
8
8
  // matched AUTH_MARKER_RE's bare "forbidden", and the codex lesson "retries burn quota" matched
9
9
  // the exhausted rule. Three fixes, one per failure mode:
10
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.
11
+ // was told, not the CLI speaking; classification sees only the CLI's own output. The exact-
12
+ // match version (9b28036) caught nothing in the wild: the qwen specimen (turn 9, 2026-09-02,
13
+ // card #5868) echoed its CONTRACT — the #6049 wake text is ABOUT a hub 401, dense with "401",
14
+ // "unknown identity", "credentials" — wrapped in CLI framing no prompt line equals verbatim.
15
+ // The strip now normalizes (ANSI off, whitespace collapsed) and drops a line that CONTAINS a
16
+ // prompt line or is a wrapped FRAGMENT of one, so replay is caught however the CLI frames it.
12
17
  // · looksLikeAuthDeath — the #5405 exit-0 escalation fires only on a SHORT error-only output
13
18
  // (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.
19
+ // answer, and a warning inside it must not fail the turn. AND never when the turn produced
20
+ // REAL WORK (a new commit): the qwen specimen exited 0, committed aa3c340, and its captured
21
+ // stream still held under 400 bytes of contract echo carrying "401" — a short capture is not
22
+ // proof of a dead turn, but a shipped commit is proof of a live one.
15
23
  // · classifyFailure — "exhausted" demands an explicit quota/rate-limit message; the broad bare
16
24
  // words ("credit", "balance", bare "insufficient") labelled ordinary prose on a dead turn and
17
25
  // sent the operator to wait out a window that did not exist.
@@ -19,25 +27,69 @@ import { readFileSync } from "node:fs";
19
27
 
20
28
  export const AUTH_MARKER_RE = /unauthor|401|403|forbidden|invalid[ _-]?api[ _-]?key|authentication? failed|token expired/i;
21
29
 
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. */
30
+ /** Prompt lines (≥40 chars — a short line carries no signature) echoed back by the CLI are
31
+ * replay, not speech. Echoes are rarely byte-identical (CLI framing, terminal wrapping, ANSI
32
+ * colors — the exact-match strip caught nothing in the qwen #6049 specimen), so both sides are
33
+ * normalized (ANSI stripped, whitespace collapsed) and a long line is dropped when it CONTAINS
34
+ * a prompt line, IS a fragment of one, or contains a ≥40-char RUN of one — terminal wrapping
35
+ * breaks the line but preserves long runs inside each wrapped piece. */
36
+ const ECHO_RUN = 40;
24
37
  export function stripPromptEcho(errText, promptText) {
25
38
  const text = String(errText || "");
26
39
  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");
40
+ const norm = (l) => String(l).replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").replace(/\s+/g, " ").trim();
41
+ const promptLines = String(promptText).split("\n").map(norm).filter(Boolean);
42
+ if (!promptLines.length) return text;
43
+ const prompts = promptLines.filter(l => l.length >= 40);
44
+ // Whole-prompt normalized text, for the short-line VERBATIM check below: a short line has no
45
+ // 40-char run to fuzzy-match against (that's the ECHO_RUN heuristic for long lines), but a short
46
+ // echoed wake fragment ("check the 401 on the hub") still reappears byte-for-byte (post-
47
+ // normalization) as a line or substring of the prompt — that's still replay, not the CLI's own
48
+ // voice, and must not survive to trip looksLikeAuthDeath on a healthy exit-0 turn (#6110).
49
+ const promptFull = promptLines.join(" ");
50
+ const hasRun = (p, n) => {
51
+ for (let i = 0; i + ECHO_RUN <= p.length; i++) if (n.includes(p.slice(i, i + ECHO_RUN))) return true;
52
+ return false;
53
+ };
54
+ return text.split("\n").filter(line => {
55
+ const n = norm(line);
56
+ if (!n) return true;
57
+ // Short lines: survive unless they are a multi-word fragment (has a space — excludes a bare
58
+ // token like "codex" or "4,387" that trivially co-occurs with unrelated prompt text) that
59
+ // appears verbatim in the prompt. A genuine short CLI error the prompt never mentioned, or a
60
+ // single echoed token, still passes through untouched; a full echoed phrase does not.
61
+ if (n.length < 40) return !(n.includes(" ") && promptFull.includes(n));
62
+ return !prompts.some(p => n.includes(p) || p.includes(n) || hasRun(p, n));
63
+ }).join("\n");
29
64
  }
30
65
 
31
66
  /** A real answer is long; an auth death is a couple of lines. The opencode specimen (#5405)
32
67
  * printed its whole failure in under a hundred characters and produced nothing else. */
33
68
  export const OWN_OUTPUT_ANSWER_MIN = 400;
34
69
 
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) {
70
+ /** The #5405 rule, refined twice by #5868: exit 0 + an auth-shaped marker means FAILED only when
71
+ * the CLI's own output is short enough to be JUST the error — and NEVER when the turn did real
72
+ * work (newCommit, checked by the runner via git): a shipped commit is a live turn, whatever the
73
+ * captured stream happens to hold. */
74
+ export function looksLikeAuthDeath(ownText, realWork = false) {
75
+ if (realWork) return false;
38
76
  return String(ownText || "").length < OWN_OUTPUT_ANSWER_MIN && AUTH_MARKER_RE.test(ownText);
39
77
  }
40
78
 
79
+ /** The one-line verdict the seat's jsonl carries (#5868): why the runner judged the turn the way
80
+ * it did, phrased exactly like the runner's own "classified X because Y" log — so a pane that
81
+ * scrolls away loses nothing that the telemetry row needs to say. */
82
+ export function verdictFor(realExit, effExit, emptyOutput, ownText) {
83
+ if (realExit === 0 && effExit === 0) return "classified success because exit 0 with CLI output";
84
+ if (realExit === 0 && effExit === 1) {
85
+ if (emptyOutput) return "classified empty-output because exit 0 with no output on either stream";
86
+ const m = AUTH_MARKER_RE.exec(String(ownText || ""));
87
+ return `classified auth because ${m ? m[0] : "auth marker"} in the CLI's own short output`;
88
+ }
89
+ const { reason, matched } = classifyFailure(realExit, String(ownText || ""), emptyOutput);
90
+ return `classified ${reason} because ${matched}`;
91
+ }
92
+
41
93
  /** Classify one failed turn. Returns { reason, matched } — matched is the evidence excerpt the
42
94
  * runner logs, so the next misclassification is diagnosable from the seat log alone. */
43
95
  export function classifyFailure(exit, errText, emptyOutput = false) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.30",
3
+ "version": "0.18.32",
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 && 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"
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-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"
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": [