trantor 0.18.61 → 0.18.63

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.
Files changed (52) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +12 -4
  3. package/bin/adopt.mjs +28 -13
  4. package/bin/advise.mjs +12 -28
  5. package/bin/app.mjs +43 -16
  6. package/bin/bridge.mjs +1 -0
  7. package/bin/cli.mjs +31 -0
  8. package/bin/connect.mjs +14 -2
  9. package/bin/crew/core.mjs +6 -8
  10. package/bin/crew/worktrees.mjs +7 -5
  11. package/bin/crew-payload.mjs +13 -2
  12. package/bin/crew-runner.mjs +124 -34
  13. package/bin/doctor.mjs +85 -47
  14. package/bin/drill-report.mjs +1 -1
  15. package/bin/drill-report.test.mjs +1 -0
  16. package/bin/new.mjs +3 -1
  17. package/bin/provider.mjs +18 -18
  18. package/bin/reconcile.mjs +22 -4
  19. package/bin/secrets.mjs +83 -0
  20. package/bin/takeover.mjs +4 -4
  21. package/bin/turn-watchdog.mjs +35 -23
  22. package/docs/BUILD-DOCTRINE.md +117 -0
  23. package/hooks/lib/hollow-move.mjs +52 -1
  24. package/hooks/sessionstart.mjs +36 -3
  25. package/hub/events.mjs +2 -1
  26. package/hub/routes/admin.mjs +47 -32
  27. package/hub/routes/cards.mjs +47 -44
  28. package/hub/store.mjs +22 -6
  29. package/lib/autonomy.mjs +2 -1
  30. package/lib/balances.mjs +3 -1
  31. package/lib/classify-failure.mjs +17 -8
  32. package/lib/decode.mjs +13 -0
  33. package/lib/identity.mjs +4 -2
  34. package/lib/launch-env.mjs +23 -0
  35. package/lib/model-catalog.mjs +8 -19
  36. package/lib/project.mjs +110 -21
  37. package/lib/provider-keys.mjs +5 -3
  38. package/lib/providers.mjs +3 -2
  39. package/lib/seat-record.mjs +3 -14
  40. package/lib/seat-why.mjs +4 -3
  41. package/lib/seats.mjs +15 -11
  42. package/lib/secrets.mjs +232 -0
  43. package/lib/state/driver.mjs +20 -6
  44. package/lib/state/flags.mjs +26 -0
  45. package/lib/subagent-manifest.mjs +2 -1
  46. package/lib/subagent-scan.mjs +0 -0
  47. package/lib/turn-policy.mjs +14 -0
  48. package/mcp.mjs +49 -16
  49. package/package.json +3 -2
  50. package/skills/crew/SKILL.md +18 -7
  51. package/skills/prd-review/SKILL.md +12 -3
  52. package/ui.html +6 -3
@@ -6,6 +6,7 @@ import { readFileSync, writeFileSync, unlinkSync, existsSync, appendFileSync, mk
6
6
  import { join, basename } from "node:path";
7
7
  import { homedir } from "node:os";
8
8
  import { resolveProject, resolveHub, withEnvFiles, hostId } from "../lib/project.mjs";
9
+ import { resolveSecrets, shadowEnv, withSecretExports } from "../lib/secrets.mjs";
9
10
  import { loadOrCreate } from "../lib/identity.mjs";
10
11
  import { signedHeaders } from "../lib/signed-fetch.mjs";
11
12
  import { ensureEnrolled } from "../lib/enroll.mjs";
@@ -19,7 +20,7 @@ import {
19
20
  import { capWake, capBcast, pickLessons, composePrompt, contractBase, baseLine } from "./crew-payload.mjs";
20
21
  import {
21
22
  cardRefs, wakeCard, carriesWork, parseTurnTokens, parseResetAt, reasonWithBalances, quotaResetAt, PARKING_REASONS,
22
- senderProjectOf, isLinkedProject, stateSkipReason,
23
+ senderProjectOf, isLinkedProject, stateSkipReason, isMessageCardTitle, OPEN_CARD_STATUSES,
23
24
  } from "../lib/turn-policy.mjs";
24
25
  import {
25
26
  auditDutyNudges, claimDutyNudges, claudeTranscriptDir, dutyEscalations, dutyNudgeDirective,
@@ -32,6 +33,7 @@ import {
32
33
  breakerVerdict, describeTurn, hasJsonSchemaFlag, parseEnvelope, renderCardTail, runStep,
33
34
  } from "../lib/state/driver.mjs";
34
35
  import { costLine } from "../lib/state/cost.mjs";
36
+ import { resolveStateFlags } from "../lib/state/flags.mjs";
35
37
 
36
38
  const AGENT = process.argv[2];
37
39
  const DIR = process.argv[3] || process.cwd();
@@ -246,7 +248,7 @@ const EFFORT_FLAG = EFFORT ? cliEffortFlag(AGENT, EFFORT) : { flag: "", text: ""
246
248
 
247
249
  // RUNNER_RULES / RUNNER_KICKOFF env overrides: the runner is also the substrate for non-crew
248
250
  // always-on seats (the fleet DUTY agent, bin/duty.mjs) whose doctrine is not "work your card".
249
- 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 a contract omits a fact you cannot proceed without, ASK — never invent the value: relay_ask(<card>, <question>) blocks the card with your question, keeps the turn owed (no park, no failure), and resumes you when the assigner's answer lands; an invented value that reads as reasoned is the worst outcome this crew ships (#7756). 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. Realigning your seat branch after the orchestrator harvested your commits is \`trantor sync\` run from your worktree: it reads the harvest receipts and refuses when an unharvested commit would be lost, so never reset or rebase onto main by hand. A contract's \`base: <sha>\` line is the integration head: start your seat branch at that sha (\`git reset --hard <sha>\` on the fresh branch), never at origin/main, which trails the orchestrator's unpushed integration commits; the object is already here as the ref \`main\`. If \`git cat-file -e <sha>\` fails, say \`cannot resolve base <sha>\` on the bus and move the card to blocked instead of reasoning from origin/main. Every testing/done note names the sha you verified against as \`verified at <sha>\`; a note without it is flagged HOLLOW. 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.`;
251
+ 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. If your wake names no card id (or cites only a message-card), make relay_board with mine:true your FIRST call — it lists the cards assigned to you (doing/testing/todo, newest first, full card shape); work your newest one (#7763). 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, then STOP: you never close your own card to done — the orchestrator runs the card's drill on the built artifact and closes it, and the hub refuses a move to done on a card with no drill line anyway; 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 testing with slop-gate failing; use 'failed' + a report if anything breaks). If a contract omits a fact you cannot proceed without, ASK — never invent the value: relay_ask(<card>, <question>) blocks the card with your question, keeps the turn owed (no park, no failure), and resumes you when the assigner's answer lands; an invented value that reads as reasoned is the worst outcome this crew ships (#7756). 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. Realigning your seat branch after the orchestrator harvested your commits is \`trantor sync\` run from your worktree: it reads the harvest receipts and refuses when an unharvested commit would be lost, so never reset or rebase onto main by hand. A contract's \`base: <sha>\` line is the integration head: start your seat branch at that sha (\`git reset --hard <sha>\` on the fresh branch), never at origin/main, which trails the orchestrator's unpushed integration commits; the object is already here as the ref \`main\`. If \`git cat-file -e <sha>\` fails, say \`cannot resolve base <sha>\` on the bus and move the card to blocked instead of reasoning from origin/main. Every testing/done note names the sha you verified against as \`verified at <sha>\`; a note without it is flagged HOLLOW. 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.`;
250
252
 
251
253
  // ---- the pulse --------------------------------------------------------------
252
254
  // RUNNER_PULSE_MS re-runs an orchestrator seat's mission note on a cadence when the bus is silent;
@@ -315,6 +317,10 @@ const RETRY_MS = (() => {
315
317
  // #7756: while an ask awaits its answer the contract's wake stays owed but is NOT redelivered —
316
318
  // the answer (re = the ask's id, or any direct word from the assigner) is what releases it.
317
319
  let awaitingAsk = null;
320
+ // #7756: set the moment an answer releases a held ask, consumed by the next deliverWake — the
321
+ // released turn RESUMES the held contract's session, so it is never "fresh" however the batch
322
+ // cites cards (the session already belongs to the contract the ask was holding).
323
+ let askReleased = false;
318
324
  // The bus is the record of a turn that ended by ASKING: an open kind:ask contract from this
319
325
  // session. deliverWake judges each clean wake turn against /contracts — an open ask renames the
320
326
  // ledger row "asked" and HOLDS the wake (no failure count, no backoff, no park) until the answer.
@@ -356,7 +362,9 @@ function loadPending() {
356
362
  // Auth failures in TURN OUTPUT: opencode prints its auth error and still exits 0 (#5405). The rules
357
363
  // live in lib/classify-failure.mjs (#5868); runTurn judges only the CLI's own output, not the echo.
358
364
  function classify(exit) {
359
- const { reason, matched } = classifyFailure(exit, lastErrText, lastEmptyOutput, lastEmptyTurn);
365
+ // lastTurnCut lets a boxed turn's 137 read as the sweep's SIGKILL (#7099); without the cut
366
+ // marker the same exit stays a crash, exactly as classifyFailure's cut rule draws it.
367
+ const { reason, matched } = classifyFailure(exit, lastErrText, lastEmptyOutput, lastEmptyTurn, lastTurnCut);
360
368
  log(`classified ${reason} because ${matched}`);
361
369
  return reason;
362
370
  }
@@ -465,9 +473,9 @@ async function balanceRows() {
465
473
  // Same key sources the crew itself uses: QWEN_API_KEY (and most others) live in
466
474
  // ~/.agent-bus/.env, not in the runner's inherited environment — reading bare process.env
467
475
  // here would report "no key" and quietly leave every silent turn classified as a crash.
468
- const { resolveKeys } = await import("../lib/provider-keys.mjs");
476
+ const { resolveKeys, keyFiles } = await import("../lib/provider-keys.mjs");
469
477
  return await Promise.race([
470
- fetchBalances(resolveKeys(process.env), { only: [AGENT] }),
478
+ fetchBalances(resolveKeys(process.env, keyFiles(), resolveSecrets(process.env)), { only: [AGENT] }),
471
479
  new Promise((r) => setTimeout(() => r([]), 4000)),
472
480
  ]);
473
481
  } catch { return []; }
@@ -536,8 +544,13 @@ async function reportHealthy() {
536
544
  }
537
545
 
538
546
  // ---- Trantor State Phase 2a — the flagged path (TDD §4.1, §4.6, §7.3) -----------------------
539
- // Off by default, and off means the transcript path runs unchanged. Reachable only when the operator
540
- // set TRANTOR_STATE_ASSEMBLE=1, the seat is `claude` (§7.3) and the CLI carries --json-schema (§6).
547
+ // Off by default — off means the transcript path runs unchanged. #7159: the flags resolve through
548
+ // the one place (lib/state/flags.mjs), launcher env first and ~/.agent-bus/.env filling the rest;
549
+ // resolved values are applied fill-only to process.env so downstream reads (runGate's GATE) agree.
550
+ const stateFlags = resolveStateFlags();
551
+ for (const [name, flag] of Object.entries(stateFlags)) {
552
+ if (flag.value && process.env[name] === undefined) process.env[name] = flag.value;
553
+ }
541
554
  const STATE_FLAG_ON = process.env[STATE_ENV] === "1";
542
555
  const STATE_SCHEMA_FILE = join(homedir(), ".agent-bus", `state-schema-${AGENT}-${PROJ}.json`);
543
556
  const STATE_MODE = (() => {
@@ -551,7 +564,7 @@ const STATE_MODE = (() => {
551
564
  } catch (e) { log(`\x1b[33mstate mode OFF — could not write ${STATE_SCHEMA_FILE}: ${e.message}\x1b[0m`); return false; }
552
565
  // #7060: the checks above prove CONFIGURATION, not the prompt, so say "armed" and name the one
553
566
  // thing that engages it; each turn then reports which path it took.
554
- log(`\x1b[36mTrantor State: ASSEMBLE armed for this seat (schema ${STATE_SCHEMA_FILE})\x1b[0m`);
567
+ log(`\x1b[36mTrantor State: ASSEMBLE armed for this seat (${STATE_ENV}=${stateFlags[STATE_ENV].value} via ${stateFlags[STATE_ENV].layer}; schema ${STATE_SCHEMA_FILE})\x1b[0m`);
555
568
  log(`\x1b[36m a turn is assembled only when a wake ASSIGNS it a card — the kickoff and every pulse run the transcript path, and each turn says which one it took\x1b[0m`);
556
569
  return true;
557
570
  })();
@@ -608,6 +621,14 @@ let statePromotedHash;
608
621
  // TRANTOR_TURN_MAX_MS ends the CLI's process group at the box and runs ONE follow-up turn in the
609
622
  // same session ("commit what is done, move the card, report") so a cut turn lands its work.
610
623
  const TURN_MAX_MS = Math.max(0, Number(process.env.TRANTOR_TURN_MAX_MS || 20 * 60 * 1000));
624
+ // #7761: the box counts LIVENESS. At the deadline a turn that moved within the stall window is
625
+ // extended one step (half the box: +10m at the default) up to TRANTOR_TURN_CEILING_MS, the one
626
+ // knob (default 60m). A ceiling at or under the box disables extension; a silent turn still ends
627
+ // at the #7752 stall window, extended or not. The watchdog owns the clock — see turn-watchdog.mjs.
628
+ const TURN_CEILING_MS = TURN_MAX_MS ? Math.max(0, Number(process.env.TRANTOR_TURN_CEILING_MS || 60 * 60 * 1000)) : 0;
629
+ const TURN_EXTEND_MS = Math.ceil(TURN_MAX_MS / 2);
630
+ const TURN_EXTENSIONS_MAX = TURN_EXTEND_MS > 0 && TURN_CEILING_MS > TURN_MAX_MS
631
+ ? Math.floor((TURN_CEILING_MS - TURN_MAX_MS) / TURN_EXTEND_MS) : 0;
611
632
  const TIME_BOX_PROMPT = "your previous turn was cut at the time box; commit what is done, move the card with a note, report in one line";
612
633
  let inFollowUp = false;
613
634
  // #6289: whether the turn that just ended was CUT at the time box. The follow-up recursion
@@ -671,7 +692,10 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
671
692
  // PRECEDENCE: each file is PREPENDED, so the list is iterated in written order, highest priority
672
693
  // first, and the CREW layer (~/.agent-bus/.env) wins. test-crew-env.mjs runs the real shell.
673
694
  const envs = [join(homedir(), ".agent-bus", ".env"), cli.env].filter(f => f && existsSync(f));
674
- cmd = withEnvFiles(cmd, envs);
695
+ // #6393: the store's keys ride the spawn env under shadow names and the shell re-exports them
696
+ // AFTER the sources ran, so the keychain wins over a stale .env line and no file is ever copied.
697
+ const secrets = resolveSecrets(process.env);
698
+ cmd = withEnvFiles(withSecretExports(cmd, Object.keys(secrets)), envs);
675
699
  log(`turn starting (${isFirst ? "fresh session" : "resume"})${MODEL ? ` · model=${MODEL}` : ""}`);
676
700
  // #7777: the one effort line — which effort parameters were set, or that the model is uncatalogued.
677
701
  if (EFFORT && EFFORT_FLAG.text) log(EFFORT_FLAG.text);
@@ -705,12 +729,23 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
705
729
  // instead of burning the box. Unlinked at turn start and again with the cut cleanup.
706
730
  const STALLF = join(homedir(), ".agent-bus", `turnstall-${AGENT}-${PROJ}`);
707
731
  try { unlinkSync(STALLF); } catch {}
732
+ // #7761: the watchdog writes the extended deadline (epoch seconds) here for the shell box to
733
+ // re-read, and one row per extension to EXTF for the ledger. Both unlinked at turn start.
734
+ const DEADLINEF = join(homedir(), ".agent-bus", `turndeadline-${AGENT}-${PROJ}`);
735
+ const EXTF = join(homedir(), ".agent-bus", `turnext-${AGENT}-${PROJ}`);
736
+ try { unlinkSync(DEADLINEF); } catch {}
737
+ try { unlinkSync(EXTF); } catch {}
738
+ const boxPlan = TURN_EXTENSIONS_MAX ? {
739
+ maxMs: TURN_MAX_MS, extendMs: TURN_EXTEND_MS, ceilingMs: TURN_CEILING_MS, extensionsMax: TURN_EXTENSIONS_MAX,
740
+ deadlineFile: DEADLINEF, extFile: EXTF, card: sessionCard || 0,
741
+ assigners: (opts.assigners || []).map(a => ({ from: a.from, id: a.id })),
742
+ } : undefined;
708
743
  // Touched by the stderr scrubber as its LAST act (the shell below); node waits for it after
709
744
  // spawnSync before reading ERRF — see the drain note at the spawnSync call.
710
745
  const DRAINF = join(homedir(), ".agent-bus", `turndrain-${AGENT}-${PROJ}`);
711
746
  try { unlinkSync(DRAINF); } catch {}
712
747
  try {
713
- writeFileSync(STAMPF, JSON.stringify({ turn: TURN, startedAt: Date.now(), runner: RUNNER_ID }));
748
+ writeFileSync(STAMPF, JSON.stringify({ turn: TURN, startedAt: Date.now(), runner: RUNNER_ID, box: boxPlan }));
714
749
  const wd = spawn(process.execPath, [join(import.meta.dirname, "turn-watchdog.mjs"), STAMPF, ERRF, String(WD_MS), SESSION, PROJ, HUB, TRANSCRIPT_DIR, TURN_DIR,
715
750
  // #7752: the stall marker only exists when a box does — with no box there is no sweep to
716
751
  // end the turn, so the watchdog stays report-only (reporting is the whole job, boxless).
@@ -732,6 +767,7 @@ ${sweep}
732
767
  deadline=$(( $(date +%s) + ${Math.ceil(TURN_MAX_MS / 1000)} ))
733
768
  while :; do
734
769
  [ -f "${STALLF}" ] && break
770
+ d=$(cat "${DEADLINEF}" 2>/dev/null); case "$d" in ""|*[!0-9]*) ;; *) deadline=$d;; esac
735
771
  [ "$(date +%s)" -ge "$deadline" ] && break
736
772
  sleep 1 & sleeppid=$!
737
773
  wait "$sleeppid"
@@ -752,7 +788,7 @@ exit $turn_exit`;
752
788
  // spawned. stdin is /dev/null: a background group that reads the terminal stops on SIGTTIN.
753
789
  detached: true,
754
790
  cwd: TURN_DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : ["ignore", "inherit", "inherit"],
755
- env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_SESSION: SESSION, RELAY_PROJECT: PROJ,
791
+ env: { ...process.env, ...shadowEnv(secrets), RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_SESSION: SESSION, RELAY_PROJECT: PROJ,
756
792
  // #6228: marks this env as belonging to PROJ, unlike a one-off RELAY_PROJECT override; crew.mjs's
757
793
  // `up` guard refuses to bring up another project's crew from a shell carrying this badge.
758
794
  TRANTOR_SEAT: PROJ,
@@ -767,7 +803,7 @@ exit $turn_exit`;
767
803
  // A BACKSTOP only, deliberately later than the shell's own box: if bash itself wedges, node
768
804
  // still ends the turn. When the in-shell box works — the normal path — this never fires, which
769
805
  // is the point: the shell kills while the tree is still walkable, node cannot.
770
- if (TURN_MAX_MS) { spawnOpts.timeout = TURN_MAX_MS + 30000; spawnOpts.killSignal = "SIGKILL"; }
806
+ if (TURN_MAX_MS) { spawnOpts.timeout = (TURN_EXTENSIONS_MAX ? TURN_CEILING_MS : TURN_MAX_MS) + 30000; spawnOpts.killSignal = "SIGKILL"; }
771
807
  const r = spawnSync("/bin/bash", ["-c", shell], spawnOpts);
772
808
  // The shell's box leaves the marker; the backstop leaves an ETIMEDOUT. Either way the turn was
773
809
  // cut, not merely failed. A stall marker (#7752) outranks the box marker: a turn silent for
@@ -775,6 +811,13 @@ exit $turn_exit`;
775
811
  const boxed = existsSync(CUTF);
776
812
  const stallCut = existsSync(STALLF);
777
813
  const cut = !!TURN_MAX_MS && (boxed || stallCut || r.error?.code === "ETIMEDOUT");
814
+ // #7761: how far the watchdog moved the box, one EXTF row per extension. boxMs is the box the
815
+ // turn actually had, so a ceiling cut reads as the ceiling, never as the 20-minute default.
816
+ let extensions = 0;
817
+ try { extensions = readFileSync(EXTF, "utf8").split("\n").filter(Boolean).length; } catch {}
818
+ const boxMs = TURN_MAX_MS + extensions * TURN_EXTEND_MS;
819
+ try { unlinkSync(DEADLINEF); } catch {}
820
+ try { unlinkSync(EXTF); } catch {}
778
821
  // DRAIN before classifying, never on a CUT turn (the sweep killed the scrubber, its marker never
779
822
  // comes). bash 3.2 `wait` skips process substitutions, so wait for DRAINF, bounded.
780
823
  if (!cut) {
@@ -790,6 +833,7 @@ exit $turn_exit`;
790
833
  try { unlinkSync(CUTF); } catch {}
791
834
  try { unlinkSync(STALLF); } catch {}
792
835
  if (stallCut) log(`\x1b[33mturn STALLED — no bytes on either stream and no transcript advance for ${Math.round(WD_MS / 60000)}m; ended at the stall window, not the ${Math.round(TURN_MAX_MS / 1000)}s box\x1b[0m`);
836
+ else if (extensions) log(`\x1b[33mturn cut at the ${Math.round(boxMs / 1000)}s box after ${extensions} liveness extension(s) (ceiling ${Math.round(TURN_CEILING_MS / 1000)}s) — CLI and every descendant ended${boxed ? "" : " (node backstop: bash itself was wedged)"}\x1b[0m`);
793
837
  else log(`\x1b[33mturn cut at the ${Math.round(TURN_MAX_MS / 1000)}s time box — CLI and every descendant ended${boxed ? "" : " (node backstop: bash itself was wedged)"}\x1b[0m`);
794
838
  }
795
839
  lastTurnCut = cut;
@@ -848,8 +892,8 @@ exit $turn_exit`;
848
892
  // #5868: the verdict rides the telemetry row so a classification survives the pane scrolling
849
893
  // away — the same "classified X because Y" shape the runner logs, in the seat's jsonl forever.
850
894
  // A silent cut names its own verdict (#7752): the exit under it is the sweep's SIGPIPE, never
851
- // a provider failure.
852
- const verdict = stallCut ? stallVerdict() : verdictFor(realExit, effExit, lastEmptyOutput, ownOut, lastEmptyTurn);
895
+ // a provider failure. A box cut passes `cut` so its 137 reads as the sweep's SIGKILL (#7099).
896
+ const verdict = stallCut ? stallVerdict() : verdictFor(realExit, effExit, lastEmptyOutput, ownOut, lastEmptyTurn, cut);
853
897
  // #6134: what the turn COST, from the CLI's own usage line. Zero means this CLI printed none —
854
898
  // never that the turn was free. `trantor seat-why` totals these into today's spend per seat.
855
899
  let tokens = parseTurnTokens(ownOut);
@@ -859,12 +903,10 @@ exit $turn_exit`;
859
903
  // actually carried counts.
860
904
  if (c) tokens = c.input + c.output + c.cache_read + c.cache_creation;
861
905
  }
862
- // #6289: every ledger row names in ONE field what happened to the turn — cut (the box ended
863
- // it), stalled (the watchdog window ended it, #7752), api-error (the CLI failed), completed —
864
- // and what it cost in tokens, even when this CLI printed no usage line (0 means "not
865
- // reported", never "free"). `cut` stays too: the drills read it. #7762: `card` binds the row
866
- // to the card the turn was working (0 = kickoff/pulse, no card) so the seat record can
867
- // attribute empty/stalled turns to the card that produced nothing.
906
+ // #6289: every ledger row names in ONE field what happened to the turn — cut, stalled (#7752),
907
+ // api-error, completed — and what it cost (0 means "not reported", never "free"). #7762: `card`
908
+ // binds the row to the card the turn worked (0 = kickoff/pulse) so the seat record attributes
909
+ // empty/stalled turns to the card that produced nothing. `cut` stays too: the drills read it.
868
910
  const outcome = cut ? (stallCut ? "stalled" : "cut") : (effExit !== 0 ? "api-error" : lastEmptyTurn ? "empty" : "completed");
869
911
  // #7756: a clean turn that ASKED its assigner is demoted-but-owed, not "completed". The judge
870
912
  // (deliverWake's /contracts read) renames the ledger row, so "asked" is what the log keeps.
@@ -875,8 +917,9 @@ exit $turn_exit`;
875
917
  const telemetryRow = { ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, card: sessionCard || 0, model: MODEL || "cli-default", duration_ms: Date.now() - t0, exit: realExit, effExit, authFailed: effExit !== realExit, emptyOutput: lastEmptyOutput, emptyTurn: lastEmptyTurn, verdict, outcome: finalOutcome, tokens };
876
918
  if (cut) telemetryRow.cut = true;
877
919
  if (stallCut) telemetryRow.stalled = true;
878
- // #7752: a cut turn's 141 is the sweep's SIGPIPE, recorded as the cut signal — never read as
879
- // a quota or crash pattern downstream.
920
+ if (extensions) { telemetryRow.extensions = extensions; telemetryRow.boxMs = boxMs; }
921
+ // #7752/#7099: a cut turn's 141/137 is the sweep's own signal (SIGPIPE/SIGKILL), recorded as
922
+ // the cut signal — never read as a quota or crash pattern downstream.
880
923
  if (cut) { const sig = cutSignalFor(realExit); if (sig) telemetryRow.cutSignal = sig; }
881
924
  telemetry(telemetryRow);
882
925
  log(`turn ended (exit ${realExit}${effExit !== realExit ? ` → effective ${effExit} (${lastEmptyOutput ? "empty-output" : "auth"})` : ""}, ${((Date.now() - t0) / 1000).toFixed(0)}s)`);
@@ -912,7 +955,7 @@ async function stateTurn({ card, observation, trigger, assigners = [] }) {
912
955
  callCli: async (prompt) => {
913
956
  // isFirst=true every step ON PURPOSE: a state step carries no `-c`, so there is no session
914
957
  // to resume and a stale sid must never be handed to one.
915
- const exit = await runTurn(prompt, true, trigger, { state: true });
958
+ const exit = await runTurn(prompt, true, trigger, { state: true, assigners });
916
959
  return { exit, stdout: lastEnvelope, cut: lastTurnCut };
917
960
  },
918
961
  executeAction: async (action) => {
@@ -1066,6 +1109,32 @@ function askedExcerpt(message) {
1066
1109
  return text.slice(0, 120);
1067
1110
  }
1068
1111
 
1112
+ // #7763/#7765: the card a wake binds to, with message-cards out of the race — a transcript of a
1113
+ // bus message parked on the board is never work, and chasing one (or a phantom id) is how a turn
1114
+ // burns on bookkeeping. Only a CONFIRMED message-card title reroutes, to the seat's OWN newest
1115
+ // open card (same statuses/ordering as relay_board's mine view); a phantom binds nothing.
1116
+ async function newestOwnCard(session, why) {
1117
+ try {
1118
+ const { tasks } = await api(`/tasks?project=${encodeURIComponent(PROJ)}`);
1119
+ const own = (Array.isArray(tasks) ? tasks : [])
1120
+ .filter((t) => t.assignee === session && OPEN_CARD_STATUSES.includes(t.status) && !isMessageCardTitle(t.title))
1121
+ .sort((a, b) => (b.updated || b.ts || 0) - (a.updated || a.ts || 0));
1122
+ if (own.length) log(`${why} — resolved to the seat's own #${own[0].id} (${own[0].status})`);
1123
+ else log(`${why} — and the seat has no open card, so this turn carries no card binding`);
1124
+ return own.length ? own[0].id : 0;
1125
+ } catch { return 0; } // board unreadable: no binding beats a guessed one
1126
+ }
1127
+ async function resolveWakeCard(messages, { session }) {
1128
+ const cited = wakeCard(messages, { session });
1129
+ if (!(cited > 0)) return 0;
1130
+ try {
1131
+ const { task } = await api(`/card?project=${encodeURIComponent(PROJ)}&id=${cited}`);
1132
+ if (!task) { log(`#${cited}: the wake cites a card this board does not have — no card binding; the mine view is the first call`); return 0; }
1133
+ if (isMessageCardTitle(task.title)) return newestOwnCard(session, `#${cited} is a message-card ("${String(task.title).slice(0, 60)}")`);
1134
+ return cited;
1135
+ } catch { return cited; } // hub unreachable: keep the pre-#7763 reading rather than drop a contract
1136
+ }
1137
+
1069
1138
  (async () => {
1070
1139
  await loadLessons();
1071
1140
  // start cursor at the CURRENT tip so we don't replay history
@@ -1248,6 +1317,7 @@ function askedExcerpt(message) {
1248
1317
  if (awaitingAsk && wake.some(m => m.to === SESSION && (Number(m.re) === awaitingAsk.id || m.from === awaitingAsk.to))) {
1249
1318
  log(`answer to ask #${awaitingAsk.id} landed — resuming with the contract re-attached`);
1250
1319
  awaitingAsk = null;
1320
+ askReleased = true;
1251
1321
  savePending(pendingWake, pendingBcast);
1252
1322
  }
1253
1323
  if (awaitingAsk) { log(`holding for the answer to ask #${awaitingAsk.id} — ${wake.length} new message(s) queued behind it`); continue; }
@@ -1279,6 +1349,10 @@ function askedExcerpt(message) {
1279
1349
  } catch { return 0; }
1280
1350
  }
1281
1351
  async function deliverWake() {
1352
+ // #7756: captured and cleared HERE so an early return below never leaks the flag into a
1353
+ // later turn — only the turn the answer released reads it.
1354
+ const resumedAfterAsk = askReleased;
1355
+ askReleased = false;
1282
1356
  const wake = pendingWake;
1283
1357
  const dutyPlan = DUTY_NUDGES
1284
1358
  ? await claimDutyNudges({
@@ -1328,22 +1402,33 @@ function askedExcerpt(message) {
1328
1402
  await loadLessons();
1329
1403
  const lessons = pickLessons(LESSONS_RAW, wakeCapped.text + " " + bcastCapped.text);
1330
1404
  const trigger = wakeForTurn.some(m => m.to === SESSION) ? "direct message" : "@mention";
1331
- // Who is owed an answer, captured BEFORE the turn: pendingWake is cleared on success.
1405
+ // Who is owed an answer, captured BEFORE the turn: pendingWake is cleared on success. The id is
1406
+ // the NEWEST wake this runner holds for that assigner (#6987): append order means first-wins
1407
+ // threaded the receipt onto a PREVIOUS turn's id — the original contract, not the answer that
1408
+ // released this turn — which kept the contract reading WAITING while the stop hook chased.
1332
1409
  const assigners = [];
1333
- for (const m of wakeForTurn) if (m.from && !assigners.some(a => a.from === m.from)) assigners.push({ from: m.from, id: m.id });
1410
+ for (const m of wakeForTurn) {
1411
+ if (!m.from) continue;
1412
+ const held = assigners.find(a => a.from === m.from);
1413
+ if (held) held.id = m.id;
1414
+ else assigners.push({ from: m.from, id: m.id });
1415
+ }
1334
1416
  const asked = askedExcerpt(wakeForTurn[0]);
1335
1417
  const tStart = Date.now();
1336
1418
  // #6134: ONE SESSION PER CARD; a different card starts a fresh CLI session and the seat is told.
1337
1419
  // #7061: bound by SHAPE, not position, so an order opening with what shipped binds the right card.
1338
- const card = wakeCard(wakeForTurn, { session: SESSION });
1339
- // #7754: the contract carries the integration head as `base: <sha>` — the wake's own line if it
1340
- // has one, else local main. A sha this worktree cannot resolve is never worked from origin/main:
1341
- // the card goes to blocked and the assigner is told, and no model turn is spent on it.
1420
+ // #7763: a message-card (or phantom) citation never binds — the seat's own newest card does.
1421
+ const card = await resolveWakeCard(wakeForTurn, { session: SESSION });
1422
+ // #7754: a `base:` sha this worktree cannot resolve is never worked from origin/main — the card
1423
+ // goes to blocked, the assigner is told, and no model turn is spent. The check keeps the id the
1424
+ // wake cites in its text: #7763's rebinding covers confirmed message-card ids only, never a
1425
+ // card cited in the text, so the unresolvable-base branch blocks the cited card.
1426
+ const baseCard = card || wakeCard(wakeForTurn, { session: SESSION });
1342
1427
  const base = contractBase(wakeForTurn) || localMainHead();
1343
- if (base && card && !resolvesHere(base)) {
1344
- const text = `cannot resolve base ${base} in ${TURN_DIR} (git cat-file -e failed) — #${card} moved to blocked; push or fetch that sha, or name one this worktree can reach`;
1428
+ if (base && baseCard && !resolvesHere(base)) {
1429
+ const text = `cannot resolve base ${base} in ${TURN_DIR} (git cat-file -e failed) — #${baseCard} moved to blocked; push or fetch that sha, or name one this worktree can reach`;
1345
1430
  log(text);
1346
- await api("/task/update", { id: card, status: "blocked", note: text, by: SESSION }).catch(() => {});
1431
+ await api("/task/update", { id: baseCard, status: "blocked", note: text, by: SESSION }).catch(() => {});
1347
1432
  const owed = assigners.map(a => a.from).filter(f => f && f !== SESSION);
1348
1433
  for (const to of (owed.length ? owed : ["all"]))
1349
1434
  await api("/send", { from: SESSION, to, text, project: PROJ, kind: "alert" }).catch(() => {});
@@ -1352,7 +1437,12 @@ function askedExcerpt(message) {
1352
1437
  return;
1353
1438
  }
1354
1439
  const baseText = base ? `\n${baseLine(base)}\n` : "";
1355
- const fresh = card > 0 && card !== sessionCard;
1440
+ // #6289: the card the SESSION belongs to reads the citation when the rebinding binds nothing —
1441
+ // #7763 decides WHICH card work binds to, never the turn shape. #7756: the turn an ask's
1442
+ // ANSWER releases is the exception — it resumes the session the held contract already owns,
1443
+ // whatever card the batch cites; only a new contract for a different card opens fresh.
1444
+ const sessionCardForTurn = card || baseCard;
1445
+ const fresh = !resumedAfterAsk && sessionCardForTurn > 0 && sessionCardForTurn !== sessionCard;
1356
1446
  if (card) sessionCard = card;
1357
1447
  const cited = [...new Set(wakeForTurn.flatMap(m => cardRefs(m.text)))];
1358
1448
  const freshText = fresh
@@ -1381,7 +1471,7 @@ function askedExcerpt(message) {
1381
1471
  // dies quietly (§4.6).
1382
1472
  observation: [stateObservation, wakeText, ctxText, againText + freshText + baseText].filter(Boolean).join("\n"),
1383
1473
  })
1384
- : await runTurn(prompt, fresh, deliveryFails ? `${trigger} (redelivery)` : trigger, { judgeOutcome: askJudge });
1474
+ : await runTurn(prompt, fresh, deliveryFails ? `${trigger} (redelivery)` : trigger, { judgeOutcome: askJudge, assigners });
1385
1475
  }
1386
1476
  finally { stopDutyNudgeWatcher(); }
1387
1477
  const secs = Math.round((Date.now() - tStart) / 1000);
package/bin/doctor.mjs CHANGED
@@ -4,14 +4,16 @@
4
4
  // quota profile, optional Scrooge brain. Prints a checklist with copy-paste fixes.
5
5
  // node bin/doctor.mjs
6
6
  import { readFileSync, readdirSync, existsSync } from "node:fs";
7
- import { join, dirname } from "node:path";
7
+ import { join, dirname, basename } from "node:path";
8
8
  import { homedir } from "node:os";
9
9
  import { execSync } from "node:child_process";
10
10
  import { fileURLToPath } from "node:url";
11
- import { resolveProject, resolveHub, DEFAULT_HUB_URL } from "../lib/project.mjs";
11
+ import { resolveProject, resolveProjectInfo, resolveHub, DEFAULT_HUB_URL, gitRoot, readProjectId, checkoutFor, orchSessionsPath, PROJECT_MARKER } from "../lib/project.mjs";
12
12
  import { loadOrCreate } from "../lib/identity.mjs";
13
13
  import { sfetchJson } from "../lib/signed-fetch.mjs";
14
14
  import { scan } from "../lib/splitbrain.mjs";
15
+ import { resolveSecrets, envFileSecrets, backendFor } from "../lib/secrets.mjs";
16
+ import { resolveStateFlags } from "../lib/state/flags.mjs";
15
17
 
16
18
  const H = homedir();
17
19
  const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -64,6 +66,36 @@ if (pkg?.version) {
64
66
  tooOld ? warn(`trantor v${pkg.version} too old — heartbeat/presence requires v0.17.0+`, "npm update -g trantor") : ok(`trantor v${pkg.version}`);
65
67
  } else warn("could not read trantor version", "reinstall: npm install -g trantor");
66
68
 
69
+ // ── project identity (#6724): a directory rename left every record under the old name and minted
70
+ // an empty twin. The id in .trantor/project.json survives a rename; this section says whether THIS
71
+ // checkout has one and names every orchestrated project whose checkout is gone, with the fix.
72
+ section("project identity");
73
+ {
74
+ const root = gitRoot(process.cwd()) || process.cwd();
75
+ const label = basename(root);
76
+ const { via } = resolveProjectInfo(process.cwd());
77
+ const marked = readProjectId(root);
78
+ if (marked) ok(`${marked} recorded in ${PROJECT_MARKER}${marked !== label ? ` — directory "${label}" is a label; the identity survived a rename` : ""}`);
79
+ else if (via === "env" || via === "worktree") note(`${PROJECT} named by ${via === "env" ? "RELAY_PROJECT" : "the seat worktree path"}; the checkout itself is unmarked`);
80
+ else note(`${PROJECT} is named by its directory; a rename would orphan its pin, board and orchestrator row → trantor project ${PROJECT} (records ${PROJECT_MARKER}; commit it)`);
81
+ // An orphan is a name with an orchestrator row (a session ran HERE) and no checkout. A pin alone
82
+ // is not evidence: projects that live on another machine are pinned here too, so pins get a note.
83
+ const orchRows = (() => { try { return [...new Set(readFileSync(orchSessionsPath(), "utf8").split("\n").map(l => l.split("\t")[0]).filter(Boolean))]; } catch { return []; } })();
84
+ const pinned = Object.keys(cfg.hubs || {});
85
+ const claim = (name) => marked
86
+ ? `if that project was renamed, claim it inside its checkout: cd <its dir> && trantor project ${name}`
87
+ : `if "${label}" is that project renamed: trantor project ${name} (the pin, board and orchestrator row then follow the id)`;
88
+ let orphans = 0;
89
+ for (const name of orchRows) {
90
+ if (checkoutFor(name)) continue;
91
+ orphans++;
92
+ warn(`orphaned identity: ${name} has an orchestrator session row${pinned.includes(name) ? " and a hub pin" : ""} but no checkout on this machine carries it`, claim(name));
93
+ }
94
+ const pinnedOnly = pinned.filter(n => !orchRows.includes(n) && !checkoutFor(n));
95
+ if (pinnedOnly.length) note(`${pinnedOnly.length} pinned project(s) with no checkout here (${pinnedOnly.slice(0, 8).join(", ")}${pinnedOnly.length > 8 ? ", …" : ""}) — fine when they live on another machine`);
96
+ if (!orphans) ok("every orchestrated project still has its checkout");
97
+ }
98
+
67
99
  // ── hub routing: is any project split across two hubs? ───────────────────────────────────────
68
100
  // Cards, messages and collision detection only work over ONE hub. A project split across two
69
101
  // breaks silently — every seat reports healthy and half the work records where nobody looks.
@@ -89,10 +121,9 @@ section("hub routing");
89
121
  }
90
122
 
91
123
  // ── duty seat: is the fleet's watcher actually alive? ────────────────────────────────────────
92
- // The duty seat sat dead for four days (2026-08-27→31) while everything else reported green.
93
- // A dead watcher raises no error of its own — it just stops producing nudges — so this row makes
94
- // that state loud: process, keepalive, hub registration and the freshness of the seat's last hub
95
- // beat, each with its fix.
124
+ // A dead watcher raises no error of its own, it just stops producing nudges (it once sat dead for
125
+ // four days while everything else reported green), so this row makes that state loud: process,
126
+ // keepalive, hub registration and the freshness of the seat's last hub beat, each with its fix.
96
127
  section("duty seat (the fleet watcher)");
97
128
  {
98
129
  const BUSD = join(H, ".agent-bus");
@@ -163,7 +194,7 @@ const CLIS = [
163
194
  auth: () => !!process.env.ANTHROPIC_API_KEY || existsSync(join(H, ".claude", ".credentials.json")) || keychainHas("Claude Code-credentials"),
164
195
  login: "claude (sign in with your Anthropic account on first run)" },
165
196
  { name: "codex", bin: "codex", wired: () => (readFileSync(join(H, ".codex", "config.toml"), "utf8")).includes("[mcp_servers.relay]"), auth: () => existsSync(join(H, ".codex", "auth.json")), login: "codex (sign in with your ChatGPT account on first run)" },
166
- // Gemini CLI was retired 2026-06-18 for free/Pro/Ultra (Google → Antigravity `agy`). Kept as an
197
+ // Gemini CLI was retired for free/Pro/Ultra (Google → Antigravity `agy`). Kept as an
167
198
  // optional seat for enterprise/paid-key holders; for everyone else the seat moved to GLM/opencode,
168
199
  // and Gemini lives on only as a Scrooge cheap-model via GEMINI_API_KEY (the API/models aren't retired).
169
200
  { name: "gemini (CLI retired 2026-06-18)", bin: "gemini", wired: () => !!read(join(H, ".gemini", "settings.json"))?.mcpServers?.relay, auth: () => existsSync(join(H, ".gemini", "oauth_creds.json")) || !!process.env.GEMINI_API_KEY || !!process.env.GOOGLE_API_KEY, login: "Gemini CLI retired 2026-06-18 (free/Pro/Ultra). Crew seat → GLM (opencode) or Antigravity `agy`. Gemini still serves as a Scrooge cheap-model via GEMINI_API_KEY." },
@@ -212,28 +243,15 @@ section("providers");
212
243
  }
213
244
 
214
245
  // ---- key attribution: WHICH key does each surface actually spend on? ------------------------
215
- // Provider keys resolve through a LAYERED lookup and nothing ever showed which layer won. On
216
- // 2026-08-25 a $14 DeepSeek day could not be explained: ~/.token-scrooge/.env held the only
217
- // DEEPSEEK_API_KEY, so Scrooge's `dev-infra` key was ALSO authenticating every crew seat (the
218
- // runner sources that file). Scrooge turned out to be 0.15% of the tokens on that key and the
219
- // crew was the other 99.85%, but the bill could not say so — one key, two jobs, one line item.
220
- //
221
- // The layers, highest priority first — this MIRRORS bin/crew-runner.mjs, which sources
222
- // ~/.agent-bus/.env last so it wins:
223
- // 1. the process environment
224
- // 2. ~/.agent-bus/.env — the CREW layer (seats: opencode/deepseek/openrouter/dsh)
225
- // 3. ~/.token-scrooge/.env — the SCROOGE layer (cheap-model grunt routing)
246
+ // A layered lookup with nothing showing which layer won once let Scrooge's key authenticate every
247
+ // crew seat (a $14 day the bill could not attribute). Precedence, highest first, mirroring the
248
+ // runner: the secret store (keychain, #6393), ~/.agent-bus/.env, ~/.token-scrooge/.env, process env.
226
249
  section("provider keys (who spends on what)");
227
250
  const KEY_VARS = ["DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "MOONSHOT_API_KEY", "ZAI_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "XAI_API_KEY", "INCEPTION_API_KEY"];
228
- // Only some of these are a CREW credential. A seat that authenticates through its own config never
229
- // reads the env var at all, so a shared value there costs nothing and flagging it is noise:
230
- // glm → opencode.json provider["zai-coding-plan"].options.apiKey (and a coding plan is flat
231
- // rate, so there is no per-token bill to attribute even in principle)
232
- // kimi → ~/.kimi/credentials codex → its own login gemini → retired
233
- // The opencode-driven seats that DO take their key from the env layer are deepseek and openrouter,
234
- // and even then only when opencode.json has not already given that provider its own key.
235
- // Getting this wrong cries wolf: the first version flagged all seven and five were false positives,
236
- // which is how a doctor becomes something you skip.
251
+ // Only some of these are a CREW credential: glm reads opencode.json, kimi its own credentials,
252
+ // codex its own login, so a shared value in their env var costs nothing and flagging it is noise.
253
+ // deepseek and openrouter DO take the env layer, and only when opencode.json gave them no literal
254
+ // key. The first version flagged all seven, five were false positives; a doctor gets skipped that way.
237
255
  const OPENCODE_CFG = read(join(H, ".config", "opencode", "opencode.json")) || {};
238
256
  const SEAT_ENV_VARS = { DEEPSEEK_API_KEY: "deepseek", OPENROUTER_API_KEY: "openrouter" };
239
257
  const ENV_TEMPLATE = /\{env:([A-Z0-9_]+)\}/;
@@ -269,14 +287,15 @@ const readEnvFile = (f) => {
269
287
  // Never print a key. The suffix is enough to match a line item in a provider console.
270
288
  const mask = (v) => (!v ? "" : v.length <= 12 ? "****" : `${v.slice(0, 5)}…${v.slice(-4)}`);
271
289
  const crewEnv = readEnvFile(CREW_ENV), scroogeEnv = readEnvFile(SCROOGE_ENV);
290
+ const storeEnv = resolveSecrets();
272
291
  let anyKey = false, shared = 0; const sharedVars = [];
273
- for (const v of KEY_VARS) {
274
- const crew = process.env[v] || crewEnv[v] || "";
275
- const scrooge = process.env[v] || scroogeEnv[v] || "";
276
- if (!crew && !scrooge) continue;
292
+ for (const v of [...new Set([...KEY_VARS, ...Object.keys(storeEnv)])]) {
293
+ // The layer that answers is named on the line, in the runner's precedence.
294
+ const answered = storeEnv[v] ? ["keychain", storeEnv[v]] : crewEnv[v] ? ["~/.agent-bus/.env (crew)", crewEnv[v]]
295
+ : scroogeEnv[v] ? ["~/.token-scrooge/.env (FALLBACK)", scroogeEnv[v]] : process.env[v] ? ["process env", process.env[v]] : null;
296
+ if (!answered) continue;
277
297
  anyKey = true;
278
- const crewSrc = process.env[v] ? "process env" : crewEnv[v] ? "~/.agent-bus/.env (crew)" : scroogeEnv[v] ? "~/.token-scrooge/.env (FALLBACK)" : "none";
279
- const crewKey = crew || scrooge;
298
+ const [crewSrc, crewKey] = answered;
280
299
  const scroogeFileKey = scroogeEnv[v] || "";
281
300
  if (!crewUsesVar(v)) {
282
301
  ok(`${v}: Scrooge only ${mask(scroogeFileKey || crewKey)} — no crew seat reads this var`);
@@ -287,11 +306,34 @@ for (const v of KEY_VARS) {
287
306
  ok(`${v}: crew ${mask(crewKey)} via ${crewSrc}${scroogeFileKey && scroogeFileKey !== crewKey ? ` · scrooge ${mask(scroogeFileKey)} via ~/.token-scrooge/.env` : ""}`);
288
307
  }
289
308
  }
290
- if (!anyKey) note("no provider API keys found in env, ~/.agent-bus/.env or ~/.token-scrooge/.env");
309
+ if (!anyKey) note("no provider API keys found in the keychain, env, ~/.agent-bus/.env or ~/.token-scrooge/.env");
291
310
  else if (!shared) ok("crew and Scrooge spend on separate keys — each shows up as its own line item");
292
311
  else {
293
312
  warn(`${shared} provider key(s) do double duty (${sharedVars.join(", ")}) — a spike on the bill cannot be attributed to the crew or to Scrooge`,
294
- `mint a second key per provider and give the CREW its own, e.g.: echo 'DEEPSEEK_API_KEY=<new-crew-key>' >> ~/.agent-bus/.env (Scrooge keeps ~/.token-scrooge/.env; the runner sources ~/.agent-bus/.env last, so it wins)`);
313
+ `mint a second key per provider and give the CREW its own, e.g.: printf '%s' '<new-crew-key>' | trantor secrets set DEEPSEEK_API_KEY (Scrooge keeps ~/.token-scrooge/.env; the store wins over every file)`);
314
+ }
315
+ // Keys still living in the file (#6393): a mode-600 file is still a file any process of yours can
316
+ // read; the store is the keychain. Named by key so the fix is one command.
317
+ const straggling = envFileSecrets();
318
+ const storeNames = Object.keys(storeEnv);
319
+ if (straggling.live.length && backendFor(process.env, { create: true }) !== "none") {
320
+ warn(`${straggling.live.length} key(s) still live in ~/.agent-bus/.env (${straggling.live.join(", ")})`,
321
+ "trantor secrets migrate (each moves to the keychain; the line becomes a `# NAME -> keychain` stub)");
322
+ } else if (straggling.live.length) {
323
+ note(`${straggling.live.length} key(s) live in ~/.agent-bus/.env (${straggling.live.join(", ")}) — no keychain on this platform; TRANTOR_SECRETS_BACKEND=file opts into a file store`);
324
+ } else if (storeNames.length) {
325
+ ok(`secret store: ${storeNames.length} key(s) in the ${backendFor()} (${storeNames.join(", ")}), none left in .env`);
326
+ }
327
+
328
+ // ── Trantor State flags (#7159): the runners resolve these through ONE place — process env first,
329
+ // ~/.agent-bus/.env second — and each line names the layer that answered, so a flag that LOOKED
330
+ // applied can no longer pass for armed.
331
+ section("trantor state flags");
332
+ {
333
+ const resolved = resolveStateFlags();
334
+ const setFlags = Object.entries(resolved).filter(([, r]) => r.layer !== "unset");
335
+ for (const [name, r] of setFlags) ok(`${name}=${r.value} via ${r.layer}`);
336
+ if (!setFlags.length) note("no state flags set — Trantor State runs dark by default (TDD §11)");
295
337
  }
296
338
 
297
339
  // brain
@@ -304,13 +346,10 @@ prof?.providers && Object.keys(prof.providers).length
304
346
  ? ok(`quota profile set (${Object.entries(prof.providers).map(([k, v]) => `${k}=${v.plan}`).join(", ")})`)
305
347
  : warn("quota profile not set — the Advisor will assume API billing everywhere", `node ${join(ROOT, "bin", "profile.mjs")} set claude=max codex=plus deepseek=api … (use YOUR real plans)`);
306
348
 
307
- // fleet — is the crew actually OPERATING, not just installed?
308
- //
309
- // Added 2026-09-09, because on that morning this command reported nine issues, every one about
310
- // provider keys and billing attribution, while the duty seat had been holding 48 undelivered
311
- // messages for 21.9 hours and the orchestrator had slept through a night of finished crew work.
312
- // Doctor checked whether credentials EXIST. Nothing checked whether the fleet was MOVING. These
313
- // two signals are both already on disk, written by the runner itself — nobody was reading them.
349
+ // fleet — is the crew actually OPERATING, not just installed? Doctor once reported nine key and
350
+ // billing issues while the duty seat had held 48 undelivered messages for 22 hours and finished
351
+ // crew work went unread overnight: credentials EXIST is not the fleet is MOVING. Both signals are
352
+ // already on disk, written by the runner itself.
314
353
  section("the fleet (is it actually running?)");
315
354
  {
316
355
  const busDir = join(H, ".agent-bus");
@@ -328,11 +367,10 @@ section("the fleet (is it actually running?)");
328
367
  const oldest = stamps.length ? Math.min(...stamps) : j?.ts;
329
368
  const hours = (Date.now() - oldest) / 3.6e6;
330
369
  const seat = f.replace(/^pending-|\.json$/g, "");
331
- // Three bands, because one flat warning per stuck queue is its own failure: this machine has
332
- // leftovers from projects that ended weeks ago, and a doctor that cries about ten of them
333
- // every run teaches you to skim past the one that matters. Under an hour is the retry ladder
334
- // doing its job. Over a week is an abandoned seat, worth tidying, not worth alarming about.
335
- // The band between is the live stall — the shape of the 2026-09-09 incident.
370
+ // Three bands, because one flat warning per stuck queue is its own failure: leftovers from
371
+ // projects that ended weeks ago would drown the one that matters. Under an hour is the retry
372
+ // ladder doing its job; over a week is an abandoned seat, worth tidying, not alarming about;
373
+ // the band between is the live stall.
336
374
  const abandoned = hours >= 24 * 7;
337
375
  if (hours >= 1 && !abandoned) {
338
376
  stuck++;
@@ -68,7 +68,7 @@ export class DrillReport {
68
68
  if (!this.map[id].autoClose || result.status !== "pass" || this.closures[id]) continue;
69
69
  const response = await signedPost("/task/update", {
70
70
  id: Number(id), project: this.project, by: this.session, status: "done",
71
- note: `trantor drill PASS\n${result.evidence.join("\n")}`.slice(0, 2000),
71
+ note: `Drill: trantor drill PASS\n${result.evidence.join("\n")}`.slice(0, 2000), // "Drill:" prefix: the hub's done gate (#6452) reads it as the drill line
72
72
  }, { project: this.project, session: this.session, timeoutMs: 15000 });
73
73
  this.closures[id] = response.ok && response.json?.task?.status === "done"
74
74
  && response.json.task.id === Number(id) && response.json.task.project === this.project
@@ -51,6 +51,7 @@ test("signed closer on an enforce hub: complete evidence closes, partial/failure
51
51
  const card = (await signedGet("/tasks?project=trantor")).json.tasks[0];
52
52
  assert.equal(card.status, "done");
53
53
  assert.equal(card.workedBy, session);
54
+ assert.match(card.log.at(-1).text, /^Drill: trantor drill PASS/); // #6452: the closer's note IS the drill line the hub gate requires
54
55
  assert.match(card.log.at(-1).text, /real evidence A/);
55
56
  assert.match(card.log.at(-1).text, /real evidence B/);
56
57
  assert.equal(JSON.parse(readFileSync(path, "utf8"))[id].closure, "done");