trantor 0.18.36 → 0.18.37

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.36",
3
+ "version": "0.18.37",
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/baton.mjs CHANGED
@@ -17,6 +17,10 @@ import { writeHandoff, spawnBaton, resolveHandoffSurface } from "../hooks/lib/ha
17
17
  const cwd = process.cwd();
18
18
  const resolved = resolveHandoffSurface({ projectDir: process.env.CLAUDE_PROJECT_DIR || cwd, sessionId: process.env.CLAUDE_SESSION_ID || "" });
19
19
  const project = resolved.project;
20
+ // #6218 — the transcript is found under the RESOLVED session directory (CLAUDE_PROJECT_DIR, the
21
+ // dir the session was launched in — how Claude itself names ~/.claude/projects), never a shell
22
+ // cwd that cd'd somewhere else: a handoff record must carry the transcript it was written from.
23
+ const sessionDir = resolved.projectDir;
20
24
 
21
25
  // Model-authored handoffs ride THIS binary too (`trantor handoff` — always the global install's
22
26
  // CURRENT code, never the plugin-cache copy a session booted with, the stale-0.18.20 bug). A piped
@@ -38,10 +42,10 @@ if (stdinIsPipe() || process.argv.includes("--latest")) {
38
42
  autoBaton();
39
43
  }
40
44
 
41
- // The active session's transcript = newest *.jsonl directly in this project's Claude dir
42
- // (~/.claude/projects/<cwd-with-slashes-as-dashes>/), excluding the subagents/ subtree.
45
+ // The active session's transcript = newest *.jsonl directly in the session dir's Claude dir
46
+ // (~/.claude/projects/<sessionDir-with-slashes-as-dashes>/), excluding the subagents/ subtree.
43
47
  function findTranscript() {
44
- const dashed = cwd.replace(/\//g, "-");
48
+ const dashed = sessionDir.replace(/\//g, "-");
45
49
  const base = join(homedir(), ".claude", "projects");
46
50
  let best = "", bestM = 0;
47
51
  let dirs = []; try { dirs = readdirSync(base).filter(d => d === dashed || d.endsWith(dashed)); } catch {}
@@ -24,7 +24,8 @@ import {
24
24
  } from "../lib/classify-failure.mjs";
25
25
  import { capWake, capBcast, pickLessons, composePrompt } from "./crew-payload.mjs";
26
26
  import {
27
- cardRef, carriesWork, parseTurnTokens, parseResetAt, reasonWithBalances, PARKING_REASONS,
27
+ cardRef, carriesWork, parseTurnTokens, parseResetAt, reasonWithBalances, quotaResetAt, PARKING_REASONS,
28
+ senderProjectOf, isLinkedProject,
28
29
  } from "../lib/turn-policy.mjs";
29
30
 
30
31
  const AGENT = process.argv[2];
@@ -304,7 +305,7 @@ if (!CLI[AGENT]) log(`'${AGENT}' is not a built-in seat — running it as an ope
304
305
 
305
306
  // RUNNER_RULES / RUNNER_KICKOFF env overrides: the runner is also the substrate for non-crew
306
307
  // always-on seats (the fleet DUTY agent, bin/duty.mjs) whose doctrine is not "work your card".
307
- 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. Path discipline: build/test from your worktree root ${TURN_DIR} with absolute paths or --manifest-path/--prefix instead of cd-ing into subdirs, and put anything that must land outside the repo under ${TURN_DIR}/.agent-bus-out/ (gitignored) — never ~/.agent-bus.`;
308
+ 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. Path discipline: build/test from your worktree root ${TURN_DIR} with absolute paths or --manifest-path/--prefix instead of cd-ing into subdirs, and put anything that must land outside the repo under ${TURN_DIR}/.agent-bus-out/ (gitignored) — never ~/.agent-bus. Cross-project action is a breach: never \`trantor up\` a crew, register a seat, or send a card/contract into a project other than ${PROJ} unless the operator ran \`trantor policy link ${PROJ} <other> --reason "<why>"\` first — the hub, the CLI and this runner all refuse it mechanically, so ask the operator to link the projects instead of routing around the refusal.`;
308
309
 
309
310
  // ---- the pulse (Scape's Lloyd/Argus loop, Trantor-shaped) --------------------
310
311
  // A message-driven seat is DEAF between messages. An orchestrator seat with a mission needs a
@@ -435,8 +436,10 @@ async function reportFailure(exit, trigger, undelivered = 0, reasonOverride = ""
435
436
  // redelivered to. So those two reasons PARK — the queue is kept, the ladder stops, and the room is
436
437
  // told once, with the reset time when the CLI printed one. `trantor up` (a restart) resumes.
437
438
  let parkAnnounced = false;
438
- async function parkSeat(reason, undelivered) {
439
- const resetAt = parseResetAt(lastErrText);
439
+ async function parkSeat(reason, undelivered, resetHint = 0) {
440
+ // A seat that went QUIET printed no wall message to parse (#6131), so its own balance row is the
441
+ // only place the reset time exists. Output first when there is any: it is this turn's evidence.
442
+ const resetAt = parseResetAt(lastErrText) || resetHint;
440
443
  const when = resetAt ? new Date(resetAt).toLocaleString() : "";
441
444
  if (!parkAnnounced) {
442
445
  parkAnnounced = true;
@@ -456,8 +459,12 @@ async function parkSeat(reason, undelivered) {
456
459
  async function balanceRows() {
457
460
  try {
458
461
  const { fetchBalances } = await import("../lib/balances.mjs");
462
+ // Same key sources the crew itself uses: QWEN_API_KEY (and most others) live in
463
+ // ~/.agent-bus/.env, not in the runner's inherited environment — reading bare process.env
464
+ // here would report "no key" and quietly leave every silent turn classified as a crash.
465
+ const { resolveKeys } = await import("../lib/provider-keys.mjs");
459
466
  return await Promise.race([
460
- fetchBalances(process.env, { only: [AGENT] }),
467
+ fetchBalances(resolveKeys(process.env), { only: [AGENT] }),
461
468
  new Promise((r) => setTimeout(() => r([]), 4000)),
462
469
  ]);
463
470
  } catch { return []; }
@@ -630,7 +637,7 @@ wait $job; turn_exit=$?
630
637
  [ -n "$boxpid" ] && kill $boxpid 2>/dev/null
631
638
  wait
632
639
  exit $turn_exit`;
633
- const r = spawnSync("/bin/bash", ["-c", shell], {
640
+ const spawnOpts = {
634
641
  // detached: bash leads its OWN process group, so the time box can kill the CLI and everything
635
642
  // it spawned with one signal instead of orphaning the model process behind a dead shell.
636
643
  // stdin is /dev/null for every seat (it already was for codex/kimi/dsh via `< /dev/null`):
@@ -638,12 +645,14 @@ exit $turn_exit`;
638
645
  // takes SIGTTIN and stops forever. Nothing here runs interactively — every CLI is in -p /
639
646
  // exec / run mode — so closing stdin is what makes the group safe.
640
647
  detached: true,
641
- // A BACKSTOP only, deliberately later than the shell's own box: if bash itself wedges, node
642
- // still ends the turn. When the in-shell box works — the normal path — this never fires, which
643
- // is the point: the shell kills while the tree is still walkable, node cannot.
644
- ...(TURN_MAX_MS ? { timeout: TURN_MAX_MS + 30000, killSignal: "SIGKILL" } : {}),
645
648
  cwd: TURN_DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : ["ignore", "inherit", "inherit"],
646
649
  env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_SESSION: SESSION, RELAY_PROJECT: PROJ,
650
+ // #6228: badges this seat's env as belonging to PROJ, distinctly from a one-off RELAY_PROJECT
651
+ // override (bin/crew.sh's own tests, and any deliberate `RELAY_PROJECT=x trantor up` from a
652
+ // plain shell, set RELAY_PROJECT alone and must keep working — only THIS marker means "the
653
+ // env I'm running in already has a project home"). crew.sh's `up` guard refuses to bring up a
654
+ // DIFFERENT project's crew from a shell carrying this badge, same as it refuses TRANTOR_ORCH.
655
+ TRANTOR_SEAT: PROJ,
647
656
  // A RUNNER-MANAGED SEAT MUST NEVER HAND ITSELF A BATON.
648
657
  //
649
658
  // The handoff machinery exists for an INTERACTIVE session: near its context limit it writes a
@@ -656,7 +665,12 @@ exit $turn_exit`;
656
665
  // sitting there days later. To the operator that reads as "why are there two duty agents".
657
666
  TRANTOR_NO_HANDOFF_SPAWN: "1", TRANTOR_NO_BATON_SPAWN: "1" },
658
667
  maxBuffer: 16 * 1024 * 1024,
659
- });
668
+ };
669
+ // A BACKSTOP only, deliberately later than the shell's own box: if bash itself wedges, node
670
+ // still ends the turn. When the in-shell box works — the normal path — this never fires, which
671
+ // is the point: the shell kills while the tree is still walkable, node cannot.
672
+ if (TURN_MAX_MS) { spawnOpts.timeout = TURN_MAX_MS + 30000; spawnOpts.killSignal = "SIGKILL"; }
673
+ const r = spawnSync("/bin/bash", ["-c", shell], spawnOpts);
660
674
  try { unlinkSync(STAMPF); } catch {} // turn over — disarm the watchdog
661
675
  // The shell's box leaves the marker; the backstop leaves an ETIMEDOUT. Either way the turn was
662
676
  // cut, not merely failed.
@@ -722,7 +736,10 @@ exit $turn_exit`;
722
736
  // #6134: what the turn COST, from the CLI's own usage line. Zero means this CLI printed none —
723
737
  // never that the turn was free. `trantor seat-why` totals these into today's spend per seat.
724
738
  const tokens = parseTurnTokens(ownOut);
725
- 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 } : {}) });
739
+ const telemetryRow = { 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 };
740
+ if (tokens) telemetryRow.tokens = tokens;
741
+ if (cut) telemetryRow.cut = true;
742
+ telemetry(telemetryRow);
726
743
  log(`turn ended (exit ${realExit}${effExit !== realExit ? ` → effective ${effExit} (${lastEmptyOutput ? "empty-output" : "auth"})` : ""}, ${((Date.now() - t0) / 1000).toFixed(0)}s)`);
727
744
  if (realExit === 0 && effExit === 0) { cmuxStatus("idle", "#8a94a6", "robot"); herdrAgent("idle"); } // finished this turn, waiting for the next
728
745
  // #5965 — TURN END. A clean exit means the seat is idle again; say so right away so the app stops
@@ -827,6 +844,20 @@ function shouldWake(message) {
827
844
  && (message.text.includes(`@${AGENT}`) || message.text.toLowerCase().includes(`${AGENT}:`));
828
845
  }
829
846
 
847
+ // #6228: the operator's declared project links, cached briefly so a burst of wakes doesn't hit
848
+ // /policy per message. Fails CLOSED on an unreachable hub (keeps whatever was last known, empty
849
+ // on a cold start) — the mechanical fence the hub enforces at write time must not go soft just
850
+ // because the read-side cache call happened to miss.
851
+ let linksCache = { at: 0, links: [] };
852
+ async function currentLinks() {
853
+ if (Date.now() - linksCache.at < 60000) return linksCache.links;
854
+ try {
855
+ const r = await api("/policy");
856
+ linksCache = { at: Date.now(), links: Array.isArray(r?.links) ? r.links : [] };
857
+ } catch {}
858
+ return linksCache.links;
859
+ }
860
+
830
861
  function askedExcerpt(message) {
831
862
  let text = String(message?.text || "").replace(/\s+/g, " ").trim();
832
863
  const nested = text.search(/\s+[·|]\s*asked\s*:/i);
@@ -932,7 +963,19 @@ function askedExcerpt(message) {
932
963
  // deafness problem: the seat would never learn what it was told (#6134).
933
964
  const bcast = [...rest.filter(m => !direct.includes(m) && !mentions.includes(m)), ...fyi];
934
965
  pendingBcast.push(...bcast); // wake-policy: plain broadcasts batch, they don't wake
935
- const wake = [...direct, ...mentions];
966
+ const wakeCandidates = [...direct, ...mentions];
967
+ // #6228: a wake naming another project (its sender's home project, not this seat's, and the
968
+ // two are not `trantor policy link`ed) is dropped without acting — never queued, never folded
969
+ // into context. One report goes back to the sender so it does not just look like silence.
970
+ const links = wakeCandidates.length ? await currentLinks() : [];
971
+ const crossProject = wakeCandidates.filter(m => !isLinkedProject(senderProjectOf(m.from), PROJ, links));
972
+ for (const m of crossProject) {
973
+ const sp = senderProjectOf(m.from) || "?";
974
+ log(`\x1b[33mcross-project wake dropped\x1b[0m — ${m.from} (${sp}) is not ${PROJ}'s project and the two are not linked`);
975
+ api("/send", { from: SESSION, to: m.from, project: sp, kind: "status",
976
+ text: `⛔ cross-project: ${SESSION} is ${PROJ}'s seat, not ${sp}'s — dropped without acting. Link them first: trantor policy link ${PROJ} ${sp} --reason "<why>"` }).catch(() => {});
977
+ }
978
+ const wake = wakeCandidates.filter(m => !crossProject.includes(m));
936
979
  if (!wake.length) { if (bcast.length) { savePending(pendingWake, pendingBcast); log(`${bcast.length} broadcast(s) batched (no wake) — ${pendingBcast.length} pending`); } continue; }
937
980
  // Queue BEFORE running the turn, and persist immediately. Everything between here and a clean
938
981
  // exit 0 — the CLI dying, the machine losing power — now leaves a record of what this seat owes.
@@ -998,11 +1041,18 @@ function askedExcerpt(message) {
998
1041
  // #6131: a silent turn on a seat whose plan reads spent is exhaustion wearing a crash's
999
1042
  // clothes. Only that one reason is ever re-read, and only from the seat's own balance rows.
1000
1043
  let reason = classify(ec);
1001
- if (reason === "empty-output") reason = reasonWithBalances(reason, await balanceRows());
1044
+ let quotaReset = 0;
1045
+ if (reason === "empty-output") {
1046
+ const rows = await balanceRows();
1047
+ reason = reasonWithBalances(reason, rows);
1048
+ // The rows that just proved the plan is spent also carry when it lifts — a park that names
1049
+ // the reset is a wait, a park that cannot is a seat the operator has to remember by hand.
1050
+ if (reason === "exhausted") quotaReset = quotaResetAt(rows);
1051
+ }
1002
1052
  savePending(pendingWake, pendingBcast);
1003
1053
  await reportFailure(ec, "message", pendingWake.length, reason);
1004
1054
  if (PARKING_REASONS.has(reason)) {
1005
- retryAt = await parkSeat(reason, pendingWake.length);
1055
+ retryAt = await parkSeat(reason, pendingWake.length, quotaReset);
1006
1056
  await notifyAssigners(assigners,
1007
1057
  `⛔ your contract is PARKED on ${SESSION} (${reason}) — not retrying · asked: "${asked}"`);
1008
1058
  lastTurnAt = Date.now();
package/bin/crew.sh CHANGED
@@ -76,6 +76,40 @@ SEATDIR="$HOME/.agent-bus/seats"; mkdir -p "$SEATDIR"
76
76
  DRY="${CREW_DRY_RUN:-0}"
77
77
  run() { if [ "$DRY" = "1" ]; then echo "[dry] $*"; else eval "$*"; fi; }
78
78
 
79
+ # ── cross-project guard (#6228) ────────────────────────────────────────────────────────────────────
80
+ # The pr-os incident: an orchestrator pane badged TRANTOR_ORCH=pr-os ran `trantor up` while cd'd into
81
+ # the crebral-com worktree and brought up seats there under its OWN session. TRANTOR_ORCH (set once by
82
+ # `trantor open`, never per-invocation) and TRANTOR_SEAT (set once by crew-runner.mjs on every seat it
83
+ # spawns, see bin/crew-runner.mjs) are both STABLE badges of "this shell already has a project home" —
84
+ # unlike RELAY_PROJECT alone, which this same script's own tests legitimately override per-invocation
85
+ # to name a target explicitly (a bare `RELAY_PROJECT=x trantor up` from an unbadged shell is the
86
+ # operator, not a breach, and must keep working). A badged shell whose badge disagrees with the `up`
87
+ # target refuses mechanically — the CLI belt to the hub's own 403 (hub.mjs crossProjectGuard).
88
+ # A declared `trantor policy link` opens the door, same exception the hub and runner honor — so this
89
+ # belt must check, not just refuse on sight. `policy.mjs check` asks the hub; CREW_TEST_POLICY_LINKS
90
+ # is a hermetic override for test-crew.sh (comma-separated `a:b` pairs) so the drill never depends on
91
+ # a real hub being reachable, or on nothing already listening on the default loopback port.
92
+ if [ "$CMD" = "up" ]; then
93
+ BADGE="${TRANTOR_ORCH:-${TRANTOR_SEAT:-}}"
94
+ if [ -n "$BADGE" ] && [ "$BADGE" != "$PROJ" ]; then
95
+ LINKED=0
96
+ if [ -n "${CREW_TEST_POLICY_LINKS+x}" ]; then
97
+ case ",${CREW_TEST_POLICY_LINKS}," in
98
+ *",$BADGE:$PROJ,"*|*",$PROJ:$BADGE,"*) LINKED=1 ;;
99
+ esac
100
+ elif node "$BUS_DIR/bin/policy.mjs" check "$BADGE" "$PROJ" >/dev/null 2>&1; then
101
+ LINKED=1
102
+ fi
103
+ if [ "$LINKED" != "1" ]; then
104
+ echo "trantor: refused — this shell is badged for '$BADGE', not '$PROJ'." >&2
105
+ echo " Cross-project action is a breach unless the operator linked the projects." >&2
106
+ echo " Run: trantor policy link $BADGE $PROJ --reason \"<why>\"" >&2
107
+ exit 1
108
+ fi
109
+ echo "trantor: '$BADGE' ↔ '$PROJ' is policy-linked — proceeding." >&2
110
+ fi
111
+ fi
112
+
79
113
  # ── STATE helpers ──────────────────────────────────────────────────────────────────────────────────
80
114
  # Row schema (TSV): PROJECT <TAB> KIND <TAB> AGENT <TAB> HANDLE
81
115
  # KIND=win HANDLE=Terminal window id (one row per agent)
package/bin/policy.mjs CHANGED
@@ -2,7 +2,10 @@
2
2
  // trantor policy — the autonomy ladder's admin surface (PRD §6): show levels + links,
3
3
  // set a project's level, declare that two projects are codependent.
4
4
  // trantor policy show | set <project> <1-4> | link <a> <b> --reason "<why>" | unlink <a> <b>
5
+ // trantor policy check <a> <b> — exit 0 (prints "yes") if linked or identical, else exit 1 ("no")
5
6
  // Drafted by scrooge (deepseek-v4-flash), integrated by the orchestrator.
7
+ // `check` is what bin/crew.sh's cross-project guard (#6228) shells out to: the CLI belt needs to
8
+ // know whether the operator already linked the two projects before it refuses `trantor up`.
6
9
  import { readFileSync } from "node:fs";
7
10
  import { join } from "node:path";
8
11
  import { homedir } from "node:os";
@@ -30,7 +33,7 @@ const post = async (hub, payload) => {
30
33
 
31
34
  const legend = { 1: "1 observe", 2: "2 warn", 3: "3 gate", 4: "4 auto" };
32
35
  function usage() {
33
- console.log('usage: trantor policy show | set <project> <1-4> | link <a> <b> --reason "<why>" | unlink <a> <b>');
36
+ console.log('usage: trantor policy show | set <project> <1-4> | link <a> <b> --reason "<why>" | unlink <a> <b> | check <a> <b>');
34
37
  process.exit(1);
35
38
  }
36
39
 
@@ -80,4 +83,21 @@ if (cmd === "unlink") {
80
83
  process.exit(0);
81
84
  }
82
85
 
86
+ if (cmd === "check") {
87
+ if (!arg1 || !arg2) usage();
88
+ if (arg1 === arg2) { console.log("yes"); process.exit(0); }
89
+ let linked = false;
90
+ for (const hub of hubs) {
91
+ try {
92
+ const data = await get(hub);
93
+ if ((data.links || []).some((l) => {
94
+ const ps = (l.projects || []).map((p) => String(p).toLowerCase());
95
+ return ps.includes(arg1.toLowerCase()) && ps.includes(arg2.toLowerCase());
96
+ })) { linked = true; break; }
97
+ } catch { /* unreachable hub: fails closed, not linked */ }
98
+ }
99
+ console.log(linked ? "yes" : "no");
100
+ process.exit(linked ? 0 : 1);
101
+ }
102
+
83
103
  usage();
package/bin/slop-gate.mjs CHANGED
@@ -43,6 +43,21 @@ if (SURFACE) {
43
43
  // excluded legacy files (hub.mjs, mcp.mjs) stay excluded here too.
44
44
  const r = spawnSync("npx", ["oxlint", ...args], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
45
45
  const out = (r.stdout || "") + (r.stderr || "");
46
+ // A gate that cannot run must not read as clean. Witnessed 2026-09-03: a seat worktree with no
47
+ // node_modules made `npx oxlint` die with an npm error, the empty output had zero hits, and
48
+ // "slop-gate clean" rode into main on 23 real anti-slop errors. the exit contract below is the
49
+ // only proof it ran; without it, exit 2 and say so.
50
+ // oxlint prints no run summary here (diagnostics only), so "it ran" is read from its exit
51
+ // contract: 0 = clean, 1 = diagnostics printed. An npm error, a launch error, an exit code oxlint
52
+ // never uses, a 1 with no diagnostic line, or "No files found" is not a verdict.
53
+ const diag = /:\d+:\d+: (error|warning)\b/.test(out);
54
+ const ran = !r.error && !/^npm (error|ERR!)/m.test(out) && !/No files found to lint/.test(out)
55
+ && (r.status === 0 || (r.status === 1 && diag));
56
+ if (!ran) {
57
+ console.error(out.trim().split("\n").slice(-6).join("\n"));
58
+ console.error("\nslop-gate: could not run oxlint, so there is no verdict and this is NOT clean. Install deps in this checkout (pnpm install) and rerun.");
59
+ process.exit(2);
60
+ }
46
61
  const hits = out.split("\n").filter(l => /error\s+anti-slop\(/.test(l));
47
62
 
48
63
  if (hits.length) {
@@ -11,7 +11,7 @@
11
11
  // wall when we know the window size. Both paths share a per-session guard so we
12
12
  // never write/spawn twice for the same context window.
13
13
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, openSync, readSync, fstatSync, closeSync, rmSync } from "node:fs";
14
- import { join, basename, dirname } from "node:path";
14
+ import { join, basename, dirname, sep } from "node:path";
15
15
  import { homedir, hostname } from "node:os";
16
16
  import { execSync, spawn } from "node:child_process";
17
17
  import { fileURLToPath } from "node:url";
@@ -621,18 +621,52 @@ export function orchProjectForSession(sid) {
621
621
  return "";
622
622
  }
623
623
 
624
+ // Does the cwd actually lie inside THIS project's ground (#6218)? Two true homes: a directory
625
+ // named for the project (the project root or anything below it — the #6074 subfolder case), and
626
+ // the project's agent-bus worktrees (<bus>/worktrees/<project>/<seat>). Pure + exported so the
627
+ // drill can pin both without touching the operator's disk layout.
628
+ export function cwdInsideProject(projectName, dir, bus = process.env.AGENT_BUS_DIR || process.env.RELAY_DATA_DIR || join(homedir(), ".agent-bus")) {
629
+ if (!projectName || !dir) return false;
630
+ const wt = join(bus, "worktrees", projectName);
631
+ if (dir === wt || dir.startsWith(wt + sep)) return true;
632
+ let cur = dir;
633
+ for (;;) {
634
+ if (basename(cur) === projectName) return true;
635
+ const parent = dirname(cur);
636
+ if (parent === cur) return false;
637
+ cur = parent;
638
+ }
639
+ }
640
+
624
641
  // The one resolver. Returns { project, projectDir, pane, surface }:
625
642
  // pane — the session's own pane id ("" when it is not a hosted pane)
626
643
  // surface — "pane" (the baton MUST take the pane leg) or "window" (today's behavior)
627
644
  // project — the REGISTERED project name; a subfolder cwd never renames it
645
+ // #6218 — the badge wins only where it is TRUE. Witnessed 2026-09-03: a run from a
646
+ // trantor-badged shell in ~/development/tiny-timer wrote "handoff saved for trantor" with
647
+ // tiny-timer's transcript inside. #6074's registration-before-cwd rule was written for a
648
+ // subfolder cwd of the SAME project; a FOREIGN project dir is a different case and must not be
649
+ // silently relabeled. So the badge (and the rest of the registration chain behind it) claims
650
+ // this handoff only when the cwd lies inside the named project's directory or one of its
651
+ // worktrees; a badged shell sitting elsewhere resolves from the cwd, and ONE warning line names
652
+ // both — the badge that lied and the project the record actually went to.
628
653
  export function resolveHandoffSurface({ projectDir, sessionId, env = process.env } = {}) {
629
654
  const dir = projectDir || env.CLAUDE_PROJECT_DIR || process.cwd();
630
655
  const badge = String(env.TRANTOR_ORCH || "").trim();
631
656
  let project = "";
632
- if (badge && badge !== "1") project = badge; // `trantor open` badge carries the name
633
- if (!project && env.RELAY_PROJECT) project = String(env.RELAY_PROJECT).trim();
634
- if (!project) project = orchProjectForSession(sessionId);
635
- if (!project) project = resolveProject(dir); // last resort: cwd (git-root aware)
657
+ let foreignBadge = "";
658
+ if (badge && badge !== "1") { // `trantor open` badge carries the name
659
+ if (cwdInsideProject(badge, dir)) project = badge;
660
+ else foreignBadge = badge; // the badge lies about this cwd
661
+ }
662
+ if (!project && !foreignBadge && env.RELAY_PROJECT) project = String(env.RELAY_PROJECT).trim();
663
+ if (!project && !foreignBadge) project = orchProjectForSession(sessionId);
664
+ // Last resort: the cwd (git-root aware). With a foreign badge, the shell's own RELAY_PROJECT
665
+ // is scrubbed from the fallback's env — it is the same registration that just lied (#6218).
666
+ if (!project) project = resolveProject(dir, foreignBadge ? { ...env, RELAY_PROJECT: "" } : env);
667
+ if (foreignBadge) {
668
+ console.error(`trantor handoff: TRANTOR_ORCH=${foreignBadge} but the working directory (${dir}) is not inside ${foreignBadge}'s project or its worktrees — saving the handoff for ${project} (resolved from the cwd)`);
669
+ }
636
670
  return { project, projectDir: dir, pane: paneSurfaceEnv(env), surface: paneSurfaceEnv(env) ? "pane" : "window" };
637
671
  }
638
672
 
package/hub.mjs CHANGED
@@ -1101,6 +1101,61 @@ function projectFromRequest(P, q, b) {
1101
1101
  }
1102
1102
  return canon(String(b?.project || q?.project || "").slice(0, 80));
1103
1103
  }
1104
+ // --- Cross-project guard (#6228) -----------------------------------------------------------
1105
+ // scopeAllows/canRead answer "can this identity touch project P at all" — and most identities
1106
+ // are minted project:"*" role:"owner" by defaultScopesFor (any non-agent kind: an orchestrator,
1107
+ // genesis, a human). That is not a project fence; it is exactly the loophole the pr-os
1108
+ // orchestrator walked through to register seats and post contracts into crebral-com from its
1109
+ // own session, nothing it was ever working. This is the fence: does the CALLER's own project
1110
+ // (its identity name's "kind:project" suffix, the same convention defaultScopesFor reads) match
1111
+ // the project the request ACTS on. Only a declared `trantor policy link` (state.orgPolicy.links,
1112
+ // the same store /policy reads and writes — POST /policy is itself OWNER_ENDPOINTS-gated) or the
1113
+ // operator's own identity (kind "human", the key /instance/supersede already treats as owner)
1114
+ // opens the door. Applies to the five endpoints that reach across sessions or mint access:
1115
+ // /send, /task, /task/update, /register, /invite.
1116
+ const CROSS_PROJECT_ENDPOINTS = new Set(["/send", "/task", "/task/update", "/register", "/invite"]);
1117
+ function projectsLinked(a, b) {
1118
+ if (!a || !b || a === b) return true;
1119
+ return overseerPolicy().links.some(l => {
1120
+ const ps = (l.projects || []).map(p => canon(p));
1121
+ return ps.includes(a) && ps.includes(b);
1122
+ });
1123
+ }
1124
+ // The caller's home project, by the SAME "name suffix after the colon" rule defaultScopesFor
1125
+ // uses to mint a fresh identity's default scope. An identity with no colon in its name (a bare
1126
+ // human alias, or a tool identity never given a project) has no home to fence — nothing to check.
1127
+ function callerProject(auth) {
1128
+ const name = String(auth?.identity?.name || "");
1129
+ return name.includes(":") ? canon(name.slice(name.lastIndexOf(":") + 1)) : "";
1130
+ }
1131
+ function crossProjectTarget(P, b) {
1132
+ if (P === "/send") {
1133
+ if (!b?.to || b.to === "all") return ""; // broadcast: existing hub-wide behavior, untouched
1134
+ const to = String(b.to);
1135
+ return canon(state.peers[to]?.project || (to.includes(":") ? to.slice(to.lastIndexOf(":") + 1) : ""));
1136
+ }
1137
+ if (P === "/task" || P === "/register") return canon(String(b?.project || "").slice(0, 80));
1138
+ if (P === "/task/update") {
1139
+ const t = state.tasks.find(x => x.id === Number(b?.id));
1140
+ return t?.project || "";
1141
+ }
1142
+ if (P === "/invite") {
1143
+ // an invite MINTS access into whatever project(s) its scopes name — a wildcard scope names none.
1144
+ const scopes = Array.isArray(b?.scopes) ? b.scopes : [];
1145
+ return scopes.map(s => canon(String(s?.project || ""))).find(p => p && p !== "*") || "";
1146
+ }
1147
+ return "";
1148
+ }
1149
+ function crossProjectGuard(auth, P, b) {
1150
+ if (!CROSS_PROJECT_ENDPOINTS.has(P) || AUTH_MODE === "off" || !auth?.identity) return { ok: true };
1151
+ if (auth.identity.kind === "human") return { ok: true }; // the operator's own key
1152
+ const home = callerProject(auth);
1153
+ if (!home) return { ok: true };
1154
+ const target = crossProjectTarget(P, b);
1155
+ if (!target || target === home || projectsLinked(home, target)) return { ok: true };
1156
+ return { ok: false, code: 403,
1157
+ error: `cross-project: ${home} may not act on ${target} — cross-project action is a breach unless the operator linked the projects. Run: trantor policy link ${home} ${target} --reason "<why>"` };
1158
+ }
1104
1159
  // Is a stored baton claim still worth honouring? The claim names the instance it spared
1105
1160
  // (`exceptInstanceId`), so we defer to THAT instance only while it is still being seen. A claimant
1106
1161
  // that died stops muzzling its twins; one that comes back starts again. Records claimed before
@@ -1516,6 +1571,8 @@ const server = http.createServer(async (req, res) => {
1516
1571
  }
1517
1572
  const scopes = (Array.isArray(bi.scopes) ? bi.scopes : []).map(cleanScope).filter(Boolean).slice(0, 20);
1518
1573
  if (!scopes.length) return json(res, 400, { error: "scopes required" });
1574
+ const cpg = crossProjectGuard(auth, P, { scopes });
1575
+ if (!cpg.ok) return json(res, cpg.code, { error: cpg.error });
1519
1576
  // Honour the requested TTL. A 60s FLOOR here silently inflated `ttlSec: 1` to a minute, so a
1520
1577
  // token the caller asked to expire in a second stayed valid — and an expired-token test passed
1521
1578
  // a live token straight through /enroll. Cap the ceiling, never the floor.
@@ -1530,7 +1587,10 @@ const server = http.createServer(async (req, res) => {
1530
1587
  const authz = authorize(auth, req.method, P, projectFromRequest(P, q, b0));
1531
1588
  if (!authz.ok) return json(res, authz.code || 403, { error: authz.error || "forbidden" });
1532
1589
  if (req.method === "POST" && P === "/register") {
1533
- const b = await body(req); touch(b.session, b.status, b.project, b.hookVersion, auth);
1590
+ const b = await body(req);
1591
+ const cpg = crossProjectGuard(auth, P, b);
1592
+ if (!cpg.ok) return json(res, cpg.code, { error: cpg.error });
1593
+ touch(b.session, b.status, b.project, b.hookVersion, auth);
1534
1594
  // WHO is this, really: the LLM brand + the exact model currently loaded. In-memory like the
1535
1595
  // rest of presence — the next heartbeat re-supplies it after a restart.
1536
1596
  const pr = state.peers[b.session];
@@ -1932,7 +1992,10 @@ const server = http.createServer(async (req, res) => {
1932
1992
  }
1933
1993
  // --- Kanban tasks ---
1934
1994
  if (req.method === "POST" && P === "/task") { // create a card
1935
- const b = await body(req); touch(b.by, undefined, b.project, undefined, auth);
1995
+ const b = await body(req);
1996
+ const cpg = crossProjectGuard(auth, P, b);
1997
+ if (!cpg.ok) return json(res, cpg.code, { error: cpg.error });
1998
+ touch(b.by, undefined, b.project, undefined, auth);
1936
1999
  if (b.title !== undefined) b.title = stripNulText(b.title);
1937
2000
  if (b.note !== undefined) b.note = stripNulText(b.note);
1938
2001
  const st0 = ["todo","doing","testing","failed","done","blocked"].includes(b.status) ? b.status : "todo";
@@ -2088,6 +2151,8 @@ const server = http.createServer(async (req, res) => {
2088
2151
  if (req.method === "POST" && P === "/task/update") { // move/edit a card
2089
2152
  const b = await body(req); const t = state.tasks.find(x => x.id === Number(b.id));
2090
2153
  if (!t) return json(res, 404, { error: "no such task" });
2154
+ const cpg = crossProjectGuard(auth, P, b);
2155
+ if (!cpg.ok) return json(res, cpg.code, { error: cpg.error });
2091
2156
  if (b.title !== undefined) b.title = stripNulText(b.title);
2092
2157
  if (b.note !== undefined) b.note = stripNulText(b.note);
2093
2158
  // Board integrity (#5406): a card can never change hands silently. The assignee is frozen once
@@ -2790,6 +2855,8 @@ const server = http.createServer(async (req, res) => {
2790
2855
  const secretCheck = assertNoSecrets(text);
2791
2856
  if (!secretCheck.ok) return json(res, 400, { error: "secret detected", kinds: secretCheck.kinds || [] });
2792
2857
  if (auth?.identity && String(b.from) !== String(auth.identity.name || "")) return json(res, 403, { error: "from must match signer" });
2858
+ const cpg = crossProjectGuard(auth, P, b);
2859
+ if (!cpg.ok) return json(res, cpg.code, { error: cpg.error });
2793
2860
  touch(b.from, undefined, undefined, undefined, auth);
2794
2861
  // attribute the message to a project so the dashboard can show it in that project's lane.
2795
2862
  // explicit b.project wins; else the sender's known project; else parsed from a "host:project" id.
package/lib/balances.mjs CHANGED
@@ -152,8 +152,61 @@ export const ADAPTERS = [
152
152
  return { remainingPct, plan, resetTime: head?.nextResetTime || null, detail: "" };
153
153
  },
154
154
  },
155
+ {
156
+ // #6131. The token plan's graded remaining-% lives ONLY behind the QwenCloud console's
157
+ // cookie-authenticated API (platform-api.qianwenai.com/tokenplan/personal/api/v2/usage — the
158
+ // console SPA calls it with a browser session; an API key gets the login page back). What the
159
+ // KEY can read is the plan's WALL: the inference gateway answers 429 insufficient_quota with a
160
+ // retry-after and the reset time the moment the 7-day plan is spent. That is exactly the state
161
+ // this row exists for — a seat that is about to go quiet — so it reads the wall, not the gauge,
162
+ // and says "unknown" rather than inventing a percentage it never measured.
163
+ //
164
+ // The probe consumes NO tokens. `messages: []` is an invalid request the model never sees, but
165
+ // the quota gate runs BEFORE that validation. Captured live 2026-09-03 against the spent plan:
166
+ // a real model + empty messages → the same 429 as a real completion; an unknown model → 404
167
+ // model_not_found; a bad key → 401 invalid_api_key. So the gate sits between model lookup and
168
+ // request validation, and an empty-messages probe is the cheapest thing that can trip it.
169
+ provider: "qwen", label: "Qwen", kind: "quota", match: ["qwen"], envKeys: ["QWEN_API_KEY"],
170
+ async fetch(key, env = {}) {
171
+ const base = String(env.QWEN_BASE_URL || "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1").replace(/\/+$/, "");
172
+ // Ask the plan which models it has rather than hard-coding an id that a rename would break
173
+ // (and this doubles as the auth check: a rejected key 401s here, before the probe).
174
+ const model = ((await getJSON(`${base}/models`, key)).data || [])[0]?.id;
175
+ if (!model) throw new Error("token plan lists no models");
176
+ const r = await fetch(`${base}/chat/completions`, {
177
+ method: "POST",
178
+ headers: { Authorization: `Bearer ${key}`, "content-type": "application/json", Accept: "application/json" },
179
+ body: JSON.stringify({ model, messages: [] }),
180
+ signal: AbortSignal.timeout(TIMEOUT),
181
+ });
182
+ let body; try { body = JSON.parse(await r.text()); } catch { body = null; }
183
+ const err = body?.error || {};
184
+ if (r.status === 401 || err.code === "invalid_api_key") throw new Error("invalid API key");
185
+ if (r.status === 429 || err.code === "insufficient_quota") {
186
+ // retry-after is authoritative (seconds, exact); the message carries the same instant as
187
+ // "MM-DD HH:MM:SS UTC" with no year, so it is only the fallback.
188
+ const secs = num(r.headers.get("retry-after"));
189
+ const resetTime = secs > 0 ? Date.now() + secs * 1000 : qwenResetFromMessage(err.message);
190
+ return { remainingPct: 0, plan: "token plan", resetTime, detail: "7-day token plan exhausted" };
191
+ }
192
+ // The gate let the request through, so the plan still has room — but how much is console-only.
193
+ return { remainingPct: null, plan: "token plan", resetTime: null, detail: "plan active (remaining % is console-only)" };
194
+ },
195
+ },
155
196
  ];
156
197
 
198
+ /// "The quota will reset at 09-09 14:32:00 UTC." — Qwen omits the year, so assume the current one
199
+ /// and roll forward when that lands in the past (the plan resets ahead of now, never behind it).
200
+ export function qwenResetFromMessage(msg, now = Date.now()) {
201
+ const m = /reset at (\d{2})-(\d{2}) (\d{2}):(\d{2})(?::(\d{2}))? UTC/i.exec(String(msg || ""));
202
+ if (!m) return null;
203
+ const [, mo, d, h, mi, s] = m;
204
+ const at = (year) => Date.UTC(year, Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s || 0));
205
+ const year = new Date(now).getUTCFullYear();
206
+ const t = at(year);
207
+ return Number.isFinite(t) ? (t >= now ? t : at(year + 1)) : null;
208
+ }
209
+
157
210
  // thresholds: prepaid by currency, quota by percent-remaining. Override via config.json `lowBalance`
158
211
  // (currency keys) and `lowQuotaPct`.
159
212
  export const DEFAULT_LOW = { USD: 5, CNY: 35, EUR: 5 };
@@ -203,7 +256,8 @@ export async function fetchBalances(env = process.env, opts = {}) {
203
256
  const envKey = a.envKeys.find((k) => env[k]);
204
257
  if (!envKey && !a.keyless) return null; // configured but no key in env → can't query
205
258
  const base = { provider: a.provider, label: a.label, kind: a.kind, via: envKey || "oauth" };
206
- try { return { ...base, ok: true, ...(await a.fetch(envKey ? env[envKey] : undefined)) }; }
259
+ // `env` rides along for adapters that read more than a key (Qwen's base URL); the rest ignore it.
260
+ try { return { ...base, ok: true, ...(await a.fetch(envKey ? env[envKey] : undefined, env)) }; }
207
261
  catch (e) { return { ...base, ok: false, error: String(e?.message || e) }; }
208
262
  });
209
263
  const rows = (await Promise.all(jobs)).filter(Boolean);
@@ -278,7 +332,7 @@ export function fmtBalance(e) {
278
332
  }
279
333
 
280
334
  function fmtReset(t) {
281
- const ms = typeof t === "number" ? t : Date.parse(t);
335
+ const ms = Number.isFinite(t) ? t : Date.parse(t);
282
336
  if (!ms || isNaN(ms)) return "";
283
337
  const hrs = (ms - Date.now()) / 3600e3;
284
338
  if (hrs < 0) return "soon";
package/lib/project.mjs CHANGED
@@ -18,8 +18,11 @@ export function gitRoot(dir) {
18
18
  }
19
19
 
20
20
  // Stable project key for a working directory. RELAY_PROJECT > git-root basename > cwd basename.
21
- export function resolveProject(cwd = process.cwd()) {
22
- if (process.env.RELAY_PROJECT) return process.env.RELAY_PROJECT.slice(0, 80);
21
+ // The env comes in as a parameter (#6218): the handoff resolver must be able to resolve the cwd
22
+ // WITHOUT the shell's RELAY_PROJECT — a badge that lied about the cwd must not get a second
23
+ // voice through the fallback's own env read.
24
+ export function resolveProject(cwd = process.cwd(), env = process.env) {
25
+ if (env.RELAY_PROJECT) return env.RELAY_PROJECT.slice(0, 80);
23
26
  const root = gitRoot(cwd);
24
27
  // A LINKED WORKTREE must resolve to its MAIN repo's name, not its own directory name. Seat
25
28
  // worktrees live at ~/.agent-bus/worktrees/<project>/<agent>, so the old basename rule named the
@@ -109,6 +109,42 @@ export function reasonWithBalances(reason, rows) {
109
109
  return reason === "empty-output" && quotaSpent(rows) ? "exhausted" : reason;
110
110
  }
111
111
 
112
+ /// #6131: when the seat said nothing, its balance row is the only place the reset time exists —
113
+ /// `parseResetAt` had no output to read. The EARLIEST spent row wins, because the seat is usable
114
+ /// again the moment the first of its walls lifts. 0 when no spent row named a time.
115
+ export function quotaResetAt(rows, now = Date.now()) {
116
+ const times = (Array.isArray(rows) ? rows : []).flatMap((r) => {
117
+ if (!r || !r.ok) return [];
118
+ if (r.kind === "quota") return r.remainingPct != null && r.remainingPct <= 0 ? [r.resetTime] : [];
119
+ if (r.kind === "windows") {
120
+ return (r.windows || []).filter((w) => w.locked || (w.usedPct != null && w.usedPct >= 100)).map((w) => w.resetsAt);
121
+ }
122
+ return [];
123
+ }).map((t) => (Number.isFinite(t) ? t : Date.parse(t))).filter((t) => Number.isFinite(t) && t > now);
124
+ return times.length ? Math.min(...times) : 0;
125
+ }
126
+
112
127
  /// Seats that park rather than retry. A backend error is the provider having a bad minute and the
113
128
  /// ladder is exactly right for it; a spent plan or a rejected key will not fix itself on a timer.
114
129
  export const PARKING_REASONS = new Set(["exhausted", "auth"]);
130
+
131
+ /// #6228: the sender's home project, by the same "name suffix after the last colon" convention
132
+ /// every crew/orch identity is minted with (isRunnerSession in crew-runner.mjs uses the mirror
133
+ /// check). A session id with no colon (a bare human alias) has no home project to fence.
134
+ export function senderProjectOf(session) {
135
+ const s = String(session || "");
136
+ return s.includes(":") ? s.slice(s.lastIndexOf(":") + 1) : "";
137
+ }
138
+
139
+ /// #6228: is a wake from `senderProject` allowed to reach a seat whose own project is
140
+ /// `seatProject`? Same project always is; a declared `trantor policy link` (the hub's own
141
+ /// state.orgPolicy.links, mirrored to every seat via GET /policy) opens the door for two named
142
+ /// projects; anything else is a cross-project wake and must be dropped, never worked — the
143
+ /// runner's half of the guard the hub enforces at write time.
144
+ export function isLinkedProject(senderProject, seatProject, links) {
145
+ if (!senderProject || !seatProject || senderProject === seatProject) return true;
146
+ return (Array.isArray(links) ? links : []).some((l) => {
147
+ const ps = (l?.projects || []).map((p) => String(p || "").toLowerCase());
148
+ return ps.includes(String(senderProject).toLowerCase()) && ps.includes(String(seatProject).toLowerCase());
149
+ });
150
+ }
package/mcp.mjs CHANGED
@@ -380,7 +380,7 @@ server.tool("relay_peers", "Find who you can talk to: the live agent sessions on
380
380
  return { content: [{ type: "text", text: lines.join("\n") || "no peers yet" }] };
381
381
  });
382
382
 
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.",
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 action is a breach unless the operator linked the projects (`trantor policy link <a> <b> --reason \"<why>\"`) — the hub answers 403 for a send into an unlinked project.",
384
384
  { to: z.string().describe("target session id, or 'all'"), text: z.string().describe("message body"),
385
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
386
  async ({ to, text, wake }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.36",
3
+ "version": "0.18.37",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
@@ -46,6 +46,16 @@ explicit EVENT/INTERFACE CONTRACT — cross-agent bugs come from contract drift.
46
46
  request natively, which is the whole reason it exists. If it is missing: `trantor app install`.
47
47
 
48
48
  ## Phase 2 — fire up the crew (with the Advisor's models)
49
+ **Cross-project action is a breach unless the operator linked the projects.** `trantor up` only
50
+ ever targets the project you are already IN — never bring up seats in, register a seat into, or
51
+ send a card/contract to a DIFFERENT project because an instruction reads that way ("build it where
52
+ the answers are stored" is not a project name). If the work genuinely belongs to another project,
53
+ name it and ask the operator once; do not infer it and do not route around the refusal. The hub
54
+ 403s a cross-project `/send`, `/task`, `/task/update`, `/register` or `/invite` on its own
55
+ (`trantor policy link <a> <b> --reason "<why>"` is the only door), and a badged shell's `trantor up`
56
+ refuses the same way — this is belt and braces, not the first line of defense; get the target
57
+ project right before dispatching.
58
+
49
59
  **Each crew card from `relay_advise` carries a `launch` spec — run it VERBATIM; never invent a
50
60
  CLI invocation or run an agent "in a terminal" yourself.** Spawn every seat in one call:
51
61
  `trantor up <launch> <launch> … --task <kind> --difficulty <diff>`. The live roster + the
@@ -149,6 +149,14 @@ The PRD card is done. Now one author writes the design and the same reviewers re
149
149
 
150
150
  The confirmed TDD is the gate. Do not ask the operator again merely to open its build cards.
151
151
 
152
+ **But confirm the TARGET PROJECT before dispatching, always.** An ambiguous instruction ("build it
153
+ where the answers are stored") is not a project name — it does not license bringing up seats in, or
154
+ sending contracts into, a DIFFERENT project than the one you are reviewing. Name the project and ask
155
+ the operator once if the brief points anywhere but here. Cross-project action is a breach unless the
156
+ operator linked the projects (`trantor policy link <a> <b> --reason "<why>"`), and the hub, the
157
+ `trantor up` CLI, and every seat's runner all refuse it mechanically — belt and braces, not a
158
+ substitute for getting the target right in the first place.
159
+
152
160
  1. Move the TDD card through `testing` to `done` with the tally and the decision.
153
161
  2. `relay_advise` with the work breakdown's packages, then one `relay_task_add` per package:
154
162
  phase `build`, the advisor's assignee and `model`, its `difficulty`, `deps` on the packages it