trantor 0.18.51 → 0.18.53

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.51",
3
+ "version": "0.18.53",
4
4
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
5
5
  "mcpServers": {
6
6
  "relay": {
package/bin/cli.mjs CHANGED
@@ -90,6 +90,7 @@ switch (cmd) {
90
90
  case "proposals": case "proposal": run("bin/proposals.mjs"); break;
91
91
  case "inbox": run("bin/inbox.mjs"); break;
92
92
  case "duty": run("bin/duty.mjs"); break;
93
+ case "state": run("bin/state.mjs"); break;
93
94
  case "seats": case "seat": run("bin/seats.mjs"); break;
94
95
  case "seat-why": case "why": run("bin/seat-why.mjs"); break;
95
96
  case "orchestrate": run("bin/orchestrate.mjs"); break;
@@ -227,6 +228,7 @@ switch (cmd) {
227
228
  trantor hub run the hub in the foreground (setup installs it as a service instead)
228
229
  …or manage per-project hub pins: hub list · hub set <project> <url> · hub unset <project>
229
230
  seats: which project lives in which directory — seats · seats add · seats up · seats login install
231
+ trantor state a seat's working memory: show <seat> <card> [--json] · validate · reset --force · gc [--apply]
230
232
  trantor seat-why WHY a seat is down (err file, logs, pids): seat-why <agent> [--json] — quota, auth, crash, or just no pane
231
233
  trantor watch live bus feed in the terminal
232
234
  trantor inbox THIS session's unread bus messages, signed (works under enforce) — [--all] [--consume] [--json]
package/bin/crew/core.mjs CHANGED
@@ -132,8 +132,21 @@ export function gridColumns(size) {
132
132
  return columns;
133
133
  }
134
134
 
135
+ // Operator flags the RUNNER itself reads, forwarded from whoever launched the seat.
136
+ //
137
+ // The Trantor State flags are read as `process.env` INSIDE crew-runner.mjs, not by the CLI it
138
+ // spawns — so ~/.agent-bus/.env (the crew key layer) cannot set them: that file is applied to the
139
+ // spawned command, one level too deep. Without this list the flags documented in TDD §11 have no
140
+ // supported way to reach a seat at all, which is how Phase 2a came to be "enabled" with a schema
141
+ // file that was never written and a runner still on the transcript path.
142
+ const FORWARDED_ENV = ["TRANTOR_STATE", "TRANTOR_STATE_ASSEMBLE", "TRANTOR_STATE_HANDOFF", "TRANTOR_STATE_GATE"];
143
+
135
144
  export function runnerCommand(ctx, agent, model = "") {
136
- return `cd ${shellQuote(ctx.dir)} && CREW_MODEL=${shellQuote(model)} RELAY_PROJECT=${shellQuote(ctx.project)} RELAY_URL=${shellQuote(ctx.hub)} node ${shellQuote(join(ROOT, "bin/crew-runner.mjs"))} ${shellQuote(agent)} ${shellQuote(ctx.dir)}`;
145
+ const forwarded = FORWARDED_ENV
146
+ .filter((name) => process.env[name])
147
+ .map((name) => `${name}=${shellQuote(process.env[name])} `)
148
+ .join("");
149
+ return `cd ${shellQuote(ctx.dir)} && ${forwarded}CREW_MODEL=${shellQuote(model)} RELAY_PROJECT=${shellQuote(ctx.project)} RELAY_URL=${shellQuote(ctx.hub)} node ${shellQuote(join(ROOT, "bin/crew-runner.mjs"))} ${shellQuote(agent)} ${shellQuote(ctx.dir)}`;
137
150
  }
138
151
 
139
152
  export function listPids(pattern) {
@@ -24,13 +24,18 @@ 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, quotaResetAt, PARKING_REASONS,
28
- senderProjectOf, isLinkedProject,
27
+ cardRefs, wakeCard, carriesWork, parseTurnTokens, parseResetAt, reasonWithBalances, quotaResetAt, PARKING_REASONS,
28
+ senderProjectOf, isLinkedProject, stateSkipReason,
29
29
  } from "../lib/turn-policy.mjs";
30
30
  import {
31
31
  auditDutyNudges, claimDutyNudges, claudeTranscriptDir, dutyEscalations, dutyNudgeDirective,
32
32
  observedDutyNudgeIds,
33
33
  } from "../lib/duty-nudges.mjs";
34
+ import {
35
+ BREAKER_WINDOW, STATE_ENV, TURN_RESULT_SCHEMA,
36
+ breakerVerdict, describeTurn, hasJsonSchemaFlag, parseEnvelope, renderCardTail, runStep,
37
+ } from "../lib/state/driver.mjs";
38
+ import { costLine } from "../lib/state/cost.mjs";
34
39
 
35
40
  const AGENT = process.argv[2];
36
41
  const DIR = process.argv[3] || process.cwd();
@@ -289,7 +294,19 @@ const CLI = {
289
294
  openrouter: { first: `opencode run --dir {DIR}{M} "$(cat {P})"`,
290
295
  next: `opencode run --dir {DIR} -s {SID}{M} "$(cat {P})"`, mflag: " -m ", pinned: true, env: join(homedir(), ".token-scrooge", ".env") },
291
296
  claude: { first: `claude{M} -p "$(cat {P})" --dangerously-skip-permissions`,
292
- next: `claude -c{M} -p "$(cat {P})" --dangerously-skip-permissions`, mflag: " --model " },
297
+ next: `claude -c{M} -p "$(cat {P})" --dangerously-skip-permissions`,
298
+ // TRANTOR_STATE_ASSEMBLE=1 — Trantor State Phase 2a (TDD §4.6). Note what is GONE:
299
+ // `-c`. The resumed transcript is the thing this path exists to stop re-sending, so a
300
+ // state step is a fresh `claude -p` carrying the assembled prefix instead, and
301
+ // `--json-schema` holds the seat to the TurnResult grammar. Used for the first step
302
+ // of a card too: with no `-c` it IS the `first` shape, and a first step that returned
303
+ // no TurnResult would leave the run recorder a hole on turn 1.
304
+ //
305
+ // The flag is off by default and nothing above changes, so the transcript path stays
306
+ // byte-identical — test/state/test-runner-state.mjs asserts that against these very
307
+ // strings rather than against a reading of this comment.
308
+ stateNext: `claude{M} -p "$(cat {P})" --dangerously-skip-permissions --output-format json --json-schema "$(cat {S})"`,
309
+ mflag: " --model " },
293
310
  // DeepSeek Harness. Every turn is a FRESH session — headless has no resume yet — so the seat
294
311
  // relies on the wake prompt + the board (via the relay tools its profile mounts) rather than
295
312
  // conversation memory. `trantor connect` builds the ~/.dsh/profiles/trantor composition: their
@@ -576,6 +593,89 @@ async function reportHealthy() {
576
593
  cmuxStatus("ok", "#14b8a6", "check"); herdrAgent("idle");
577
594
  }
578
595
 
596
+ // ---- Trantor State Phase 2a — the flagged path (TDD §4.1, §4.6, §7.3) -----------------------
597
+ //
598
+ // OFF BY DEFAULT, and off means the transcript path runs unchanged. Three things have to be true
599
+ // before a single byte of this is reachable: the operator set TRANTOR_STATE_ASSEMBLE=1, the seat is
600
+ // `claude` (§7.3 — it is the only row whose CLI can enforce the grammar), and the installed CLI
601
+ // actually carries `--json-schema` (§6 — the minimum version is unconfirmed, so this PROBES rather
602
+ // than assuming; no flag, no state mode, and the runner says so once).
603
+ const STATE_FLAG_ON = process.env[STATE_ENV] === "1";
604
+ const STATE_SCHEMA_FILE = join(homedir(), ".agent-bus", `state-schema-${AGENT}-${PROJ}.json`);
605
+ const STATE_MODE = (() => {
606
+ if (!STATE_FLAG_ON) return false;
607
+ if (AGENT !== "claude") { log(`${STATE_ENV}=1 but this seat is '${AGENT}' — state mode is claude-only (TDD §7.3); staying on the transcript path`); return false; }
608
+ const probe = hasJsonSchemaFlag((bin, args) => spawnSync(bin, args, { encoding: "utf8", timeout: 20000 }));
609
+ if (!probe) { log(`\x1b[33m${STATE_ENV}=1 but this claude CLI has no --json-schema — state mode stays OFF (TDD §6)\x1b[0m`); return false; }
610
+ try {
611
+ mkdirSync(join(homedir(), ".agent-bus"), { recursive: true, mode: 0o700 });
612
+ writeFileSync(STATE_SCHEMA_FILE, JSON.stringify(TURN_RESULT_SCHEMA), { mode: 0o600 });
613
+ } catch (e) { log(`\x1b[33mstate mode OFF — could not write ${STATE_SCHEMA_FILE}: ${e.message}\x1b[0m`); return false; }
614
+ // #7060: this line used to read "ASSEMBLE mode ON for this seat", which is a claim about the
615
+ // PROMPT that nothing here established. What the four checks above prove is CONFIGURATION, and
616
+ // the two come apart on the literal next turn: the kickoff runs before any message exists, so it
617
+ // belongs to no card and cannot be a state step. So say what was proved — armed — and name the
618
+ // one thing that engages it. Each turn then reports which path it actually took.
619
+ log(`\x1b[36mTrantor State: ASSEMBLE armed for this seat (schema ${STATE_SCHEMA_FILE})\x1b[0m`);
620
+ 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`);
621
+ return true;
622
+ })();
623
+
624
+ // #7060: the one place a turn decides whether it is a state step, and the one place a skip is
625
+ // spoken. Returns the reason the turn is NOT assembled (already logged), or null when it is — so
626
+ // the runner reads `if (!stateSkip(...))` and cannot drift from what the operator was just told.
627
+ //
628
+ // It speaks on CHANGE, not on repetition. A pulse fires on a timer and skips for the same reason
629
+ // every time; printing that line forever is the repetition the monitoring doctrine rules out, and
630
+ // it would bury the turn where the path actually flipped. Assembling a turn clears the memory, so
631
+ // the next skip after real work always speaks. Silent when state mode is off: the IIFE above
632
+ // already said why, once, and a transcript seat has no claim here to mistake for proof.
633
+ let spokenStateSkip = null;
634
+ const stateSkip = (kind, card = 0) => {
635
+ const why = stateSkipReason({ mode: STATE_MODE, kind, breakerTripped, card });
636
+ if (STATE_MODE && why && why !== spokenStateSkip) log(`\x1b[33mTrantor State: this turn is NOT assembled — ${why}\x1b[0m`);
637
+ spokenStateSkip = why;
638
+ return why;
639
+ };
640
+
641
+ // The PREAMBLE, and it is the whole cost claim in one constant: the bytes before STATE_DELIM must
642
+ // be identical on every step or provider prefix caching never engages and the curve stays O(T).
643
+ // So it is computed ONCE, from things that do not vary per turn — no clock, no turn number, no
644
+ // wake text. Everything that changes rides in the state block, the card log, or the observation.
645
+ const STATE_PREAMBLE = `You are running on Trantor State. Your working memory for this card is the STATE block below — it is carried for you, so you do not have to re-read the conversation or the worktree to know where you are.
646
+
647
+ Answer with ONE JSON object matching the schema you were given: { "patch": Op[], "action": Action }.
648
+
649
+ { "set": { "field": "task"|"notes"|"ext.<key>", "value": ... } }
650
+ { "add": { "list": "done"|"in_flight"|"next"|"blockers", "item": { "id", "text", "paths"? } } }
651
+ { "remove": { "list": ..., "id": ... } }
652
+ { "move": { "id": ..., "from": ..., "to": ... } }
653
+
654
+ action is exactly one of { "done": true } (the card is finished), { "ask": "<question>" }, or { "continue": true } (you did real work this step and are not finished).
655
+
656
+ Rules the harness enforces, so that you do not have to guess at them:
657
+ · verify, files, cursor, rev, card and the counters are HARNESS-WRITTEN. A patch touching them is rejected.
658
+ · An item may only reach "done" with evidence. Cite the files it rests on inline — "wire the promoter @lib/x.mjs,test/test-x.mjs" — and the harness runs the gate for you. A red gate hands you the failing assertion as your next observation; it does not mark your work done and it does not mark it failed.
659
+ · Do the actual work with your own tools during this step. The patch describes what you did; it is not a plan.
660
+
661
+ ${RULES}`;
662
+
663
+ // The card log the state block is read against (§4.1's `tail`). One board read per step, the same
664
+ // call hooks/lib/handoff.mjs already makes.
665
+ async function cardTail(card) {
666
+ try {
667
+ const r = await api(`/tasks?project=${encodeURIComponent(PROJ)}`);
668
+ return renderCardTail(Array.isArray(r?.tasks) ? r.tasks : r, card);
669
+ } catch { return ""; }
670
+ }
671
+
672
+ // The patch-outcome ledger the breaker reads back. Kept in memory for this runner AND appended to
673
+ // disk by the driver, because the breaker is a per-seat rolling window and a runner restart should
674
+ // not hand a misbehaving seat a clean slate it did not earn.
675
+ let patchLedger = [];
676
+ let breakerTripped = false;
677
+ let statePromotedHash;
678
+
579
679
  // ---- the time box (#6134) --------------------------------------------------------------------
580
680
  // A turn with no ceiling is how a seat spends an afternoon on one card: the 09-02 baseline was 151
581
681
  // turns and ~16 agentic hours across the fleet. TRANTOR_TURN_MAX_MS ends the CLI's process group
@@ -602,8 +702,12 @@ let WD_CHILD = null;
602
702
  function killWatchdog() { if (WD_CHILD) { try { WD_CHILD.kill("SIGTERM"); } catch {} WD_CHILD = null; } }
603
703
  process.on("exit", killWatchdog);
604
704
 
605
- async function runTurn(prompt, isFirst, trigger = "kickoff") {
705
+ // #6969: `opts.state` is the ONLY way this function behaves differently, and it is set from one
706
+ // place (stateTurn). With it unset every line below is the path that shipped before Phase 2a.
707
+ let lastEnvelope = "";
708
+ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
606
709
  TURN++; banner(trigger);
710
+ lastEnvelope = "";
607
711
  const t0 = Date.now();
608
712
  // A fresh session must not resume the old one's id: `first` is chosen by isFirst OR a missing
609
713
  // sid, so a stale sid would quietly resume the session this turn exists to leave behind.
@@ -622,8 +726,12 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
622
726
  // #6154: a pinned seat with no sid yet resumes as FRESH — the guard below fails open, because
623
727
  // a resume without an id must fall back to a new session, never to `next`'s bare resume shape.
624
728
  let cmd = (isFirst || ((cli.sid || cli.pinned) && !sid)) ? cli.first : cli.next;
729
+ // The flagged row (TDD §4.6). `{S}` exists only in `stateNext`, so the replaceAll below is a
730
+ // no-op on every other path — which is what "flag off = byte-identical" has to mean.
731
+ if (opts.state && cli.stateNext) cmd = cli.stateNext;
625
732
  const mfrag = MODEL && cli.mflag ? `${cli.mflag}${MODEL}` : "";
626
- cmd = cmd.replaceAll("{M}", mfrag).replaceAll("{P}", pf).replaceAll("{SID}", sid).replaceAll("{DIR}", TURN_DIR);
733
+ cmd = cmd.replaceAll("{M}", mfrag).replaceAll("{P}", pf).replaceAll("{SID}", sid).replaceAll("{DIR}", TURN_DIR)
734
+ .replaceAll("{S}", STATE_SCHEMA_FILE);
627
735
  // PRECEDENCE, and it is easy to get backwards — this is the second time.
628
736
  // Each file is PREPENDED, so the one prepended LAST runs FIRST, and in shell the file that runs
629
737
  // LAST wins. To make ~/.agent-bus/.env (the CREW layer) win it must be prepended FIRST, i.e.
@@ -653,7 +761,15 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
653
761
  // topology is load-bearing (#5481): stdout+stderr must still BOTH land in ERRF, and the sid
654
762
  // path still folds stdout in via /dev/stderr → the --tee2 hop below.
655
763
  const SCRUB = `node ${join(import.meta.dirname, "..", "lib", "redact.mjs")}`;
656
- const inner = cli.sid ? `${cmd} | tee /dev/stderr` : `${cmd} | ${SCRUB} --tee ${ERRF}`;
764
+ // §4.6 names a real cost of `--output-format json`: the seat's window would print a JSON blob
765
+ // instead of prose, and the operator watches that window. So on a state step stdout goes to a
766
+ // file and the runner prints the result line and the cost line itself. stderr still streams to
767
+ // the window through the process substitution below, unchanged.
768
+ const ENVF = join(homedir(), ".agent-bus", `envelope-${AGENT}-${PROJ}.json`);
769
+ if (opts.state) { try { unlinkSync(ENVF); } catch {} }
770
+ const inner = opts.state
771
+ ? `${cmd} > ${ENVF}`
772
+ : (cli.sid ? `${cmd} | tee /dev/stderr` : `${cmd} | ${SCRUB} --tee ${ERRF}`);
657
773
  // #5684: runTurn is spawnSync, so the runner cannot watch its own turn — a DETACHED watchdog
658
774
  // does. Armed by a stamp file, disarmed when the turn ends (stamp removed below); a turn past
659
775
  // the window with no activity (transcript, worktree, or stderr — #6206: stdout silence alone
@@ -784,6 +900,12 @@ exit $turn_exit`;
784
900
  let ownOut = "";
785
901
  try { ownOut = stripPromptEcho(readFileSync(ERRF, "utf8"), readPromptText(pf)); } catch { ownOut = ""; }
786
902
  lastErrText = ownOut.slice(-4000);
903
+ if (opts.state) {
904
+ try { lastEnvelope = redactKeys(readFileSync(ENVF, "utf8")); } catch { lastEnvelope = ""; }
905
+ const env = parseEnvelope(lastEnvelope);
906
+ if (env.turn) log(`state step: ${describeTurn(env.turn)} · ${costLine(env.cost)}`);
907
+ else log(`\x1b[33mstate step: no TurnResult — ${env.error}\x1b[0m`);
908
+ }
787
909
  if (cli.sid && r.stdout) { const m = r.stdout.match(cli.sid); if (m) sid = m[1]; }
788
910
  // #6154: the opencode family prints no sid on stdout — the id comes from opencode's own DB,
789
911
  // keyed by the worktree the session was created in. Fail-open: nothing found leaves sid empty,
@@ -817,7 +939,10 @@ exit $turn_exit`;
817
939
  // never fired — the tee topology is the load-bearing fact; keep this comment with it.)
818
940
  // The judgment now runs on the ECHO-STRIPPED text (#5868): a CLI that replays the prompt but
819
941
  // does no work has still produced nothing of its own.
820
- if (realExit === 0 && effExit === 0 && !lastErrText.trim()) {
942
+ // #6969: on a state step the CLI's whole answer is the envelope, so ERRF holds only stderr and
943
+ // silence there is the NORMAL shape of a healthy turn. Judging it "empty-output" would park a
944
+ // working seat on its first clean state step.
945
+ if (realExit === 0 && effExit === 0 && !lastErrText.trim() && !lastEnvelope.trim()) {
821
946
  effExit = 1;
822
947
  lastEmptyOutput = true;
823
948
  log("\x1b[31mexit 0 but the turn produced NO output — treating as FAILED (empty-output)\x1b[0m");
@@ -827,7 +952,13 @@ exit $turn_exit`;
827
952
  const verdict = verdictFor(realExit, effExit, lastEmptyOutput, ownOut);
828
953
  // #6134: what the turn COST, from the CLI's own usage line. Zero means this CLI printed none —
829
954
  // never that the turn was free. `trantor seat-why` totals these into today's spend per seat.
830
- const tokens = parseTurnTokens(ownOut);
955
+ let tokens = parseTurnTokens(ownOut);
956
+ if (opts.state && lastEnvelope) {
957
+ const c = parseEnvelope(lastEnvelope).cost;
958
+ // 0 means "this CLI printed no usage", never "free" — so only overwrite when the envelope
959
+ // actually carried counts.
960
+ if (c) tokens = c.input + c.output + c.cache_read + c.cache_creation;
961
+ }
831
962
  // #6289: every ledger row names in ONE field what happened to the turn — cut (the box ended it),
832
963
  // api-error (the CLI failed), completed — and what it cost in tokens, even when this CLI printed
833
964
  // no usage line (0 means "not reported", never "free"). `cut` stays too: the drills read it.
@@ -843,7 +974,12 @@ exit $turn_exit`;
843
974
  // The follow-up rides the SAME session, so the model still has the turn it was cut out of and
844
975
  // only has to land it. Exactly one — a follow-up that runs long is itself boxed, and boxing a
845
976
  // boxed turn forever is the loop this card exists to end.
846
- if (cut && !inFollowUp) {
977
+ // A state step gets no prose follow-up: TIME_BOX_PROMPT is not a TurnResult prompt, and feeding
978
+ // it would break the byte-identical prefix the cost claim rests on. It is also unnecessary —
979
+ // §4.4 is explicit that a cut turn has never partially applied a patch, so what was lost is the
980
+ // dead turn's observations, and store.recover() rebuilds those from git on the next readState.
981
+ // The step is recorded with `cut: true` so §8.7 can still count it.
982
+ if (cut && !inFollowUp && !opts.state) {
847
983
  inFollowUp = true;
848
984
  try { return await runTurn(TIME_BOX_PROMPT, false, "time-box follow-up"); }
849
985
  finally { inFollowUp = false; }
@@ -851,6 +987,70 @@ exit $turn_exit`;
851
987
  return effExit;
852
988
  }
853
989
 
990
+ // What the NEXT state step opens with when the last one was rejected (§4.8: the seat's next step
991
+ // begins with the actual failing assertion). Cleared on every accepted patch.
992
+ let stateObservation = "";
993
+
994
+ // ---- Phase 2a: one state step, driven by lib/state/driver.mjs -------------------------------
995
+ //
996
+ // The runner's whole job here is transport and side effects: it hands the driver a way to run the
997
+ // CLI and a way to act on the returned action, and the driver holds the §4.1 order. Returns an
998
+ // exit code so deliverWake's success/failure ladder is untouched.
999
+ async function stateTurn({ card, observation, trigger, assigners = [] }) {
1000
+ const tail = await cardTail(card);
1001
+ const r = await runStep({
1002
+ seat: SESSION, card, project: PROJ, cwd: TURN_DIR,
1003
+ preamble: STATE_PREAMBLE, tail, observation,
1004
+ promoted: statePromotedHash,
1005
+ now: Date.now(),
1006
+ deps: {
1007
+ callCli: async (prompt) => {
1008
+ // isFirst=true every step ON PURPOSE: a state step carries no `-c`, so there is no session
1009
+ // to resume and a stale sid must never be handed to one.
1010
+ const exit = await runTurn(prompt, true, trigger, { state: true });
1011
+ return { exit, stdout: lastEnvelope, cut: lastTurnCut };
1012
+ },
1013
+ executeAction: async (action) => {
1014
+ // `ask` is the one action with an outside effect. State does NOT move cards (§4.7) — the
1015
+ // seat calls relay_task_move itself — so `done` and `continue` are recorded and nothing else.
1016
+ const ask = String(action?.ask ?? "").trim();
1017
+ if (ask) await notifyAssigners(assigners, `❓ ${SESSION} asks on #${card}: ${ask}`);
1018
+ },
1019
+ },
1020
+ });
1021
+
1022
+ statePromotedHash = r.promoted;
1023
+ if (r.patchRecord) patchLedger.push(r.patchRecord);
1024
+
1025
+ // §7.3's breaker: a seat whose rolling MALFORMED rate is over budget goes back to the transcript
1026
+ // path, and the room is told ONCE. Never repeated — a warning repeated every turn is the thing
1027
+ // the monitoring doctrine calls noise, and the condition is a state, not an event.
1028
+ if (!breakerTripped) {
1029
+ const v = breakerVerdict(patchLedger, SESSION, { window: BREAKER_WINDOW });
1030
+ if (v.tripped) {
1031
+ breakerTripped = true;
1032
+ log(`\x1b[31mstate-mode CIRCUIT BREAKER tripped — ${v.message}; falling back to the transcript path\x1b[0m`);
1033
+ await api("/send", {
1034
+ from: SESSION, to: "all", project: PROJ, kind: "status",
1035
+ text: `⚠️ ${SESSION} left Trantor State: ${v.message} (TDD §7.3 breaker) — back on the transcript path`,
1036
+ }).catch(() => {});
1037
+ }
1038
+ }
1039
+
1040
+ if (!r.ok) {
1041
+ // A rejection is NOT a failed turn. The patch was refused, the state is untouched, and the
1042
+ // rejection is the next step's observation — which is the loop working, not the seat dying.
1043
+ // Only the transport's own exit decides the runner's ladder, and a rejected patch on an exit-0
1044
+ // CLI is an exit-0 turn.
1045
+ log(`state step rejected (${r.code}): ${String(r.message).slice(0, 200)}`);
1046
+ stateObservation = r.observation;
1047
+ return r.step && r.step.exit ? r.step.exit : 0;
1048
+ }
1049
+ stateObservation = "";
1050
+ return 0;
1051
+ }
1052
+
1053
+
854
1054
  // ---- main loop ----
855
1055
  const KICKOFF = process.env.CREW_KICKOFF ||
856
1056
  `You just joined (your arrival was already announced on the bus). 1) relay_inbox — if a contract for you is already waiting, do it now per the Rules. 2) End your turn.\n\n${RULES}`;
@@ -1011,11 +1211,22 @@ function askedExcerpt(message) {
1011
1211
  restored.bcast.filter(isExpiredHubAlert).length;
1012
1212
  let pendingWake = restored.wake.filter(shouldWake);
1013
1213
  let pendingBcast = restored.bcast.filter(m => !isExpiredHubAlert(m) && !isReceipt(m) && !isStatusBroadcast(m));
1014
- if (shed) log(`\x1b[33mdropped ${shed} expired hub staleness alert(s) older than ${Math.round(HUB_ALERT_TTL_MS / 60000)}m — they describe conditions that have long since changed\x1b[0m`);
1214
+ if (shed) {
1215
+ log(`\x1b[33mdropped ${shed} expired hub staleness alert(s) older than ${Math.round(HUB_ALERT_TTL_MS / 60000)}m — they describe conditions that have long since changed\x1b[0m`);
1216
+ // Write the shed queue back NOW rather than waiting for the next failed delivery to persist it.
1217
+ // Caught live on 2026-09-09: after a restart shed 3 of 4, `trantor duty status` still reported
1218
+ // 4 held, because status reads the FILE and the file was still the pre-shed one. Disk and memory
1219
+ // disagreeing is the whole class of bug this day was about — a health check cannot be honest if
1220
+ // the state it reads is stale.
1221
+ savePending(pendingWake, pendingBcast);
1222
+ }
1015
1223
  let retryAt = 0; // 0 = deliver at the next opportunity
1016
1224
  let deliveryFails = 0; // consecutive failed attempts at the SAME pending batch
1017
1225
  if (pendingWake.length) log(`\x1b[33m${pendingWake.length} message(s) survived from a previous run — redelivering\x1b[0m`);
1018
1226
 
1227
+ // #7060: the turn the boot line was read as a promise about. It is a transcript turn by
1228
+ // construction and now says so, in the same breath as the line that armed the mode.
1229
+ stateSkip("kickoff");
1019
1230
  const ec0 = await runTurn(composedTurn({ base: KICKOFF, lessons: pickLessons(LESSONS_RAW, "") }), true, "kickoff");
1020
1231
  if (ec0) await reportFailure(ec0, "kickoff", pendingWake.length); // a failed kickoff = the "fired up, died, nobody knew" case
1021
1232
  let lastTurnAt = Date.now();
@@ -1026,6 +1237,7 @@ function askedExcerpt(message) {
1026
1237
  // pulse first: a due mission beat runs even on a silent bus. Measured from the END of the
1027
1238
  // last turn, so a long turn doesn't stack an immediate pulse on top of itself.
1028
1239
  if (PULSE_MS && Date.now() - lastTurnAt >= PULSE_MS) {
1240
+ stateSkip("pulse");
1029
1241
  const ecp = await runTurn(composedTurn({ base: PULSE_PROMPT + "\n\n", rulesText: RULES, lessons: pickLessons(LESSONS_RAW, PULSE_PROMPT) }), false, "pulse");
1030
1242
  if (ecp) await reportFailure(ecp, "pulse"); else await reportHealthy();
1031
1243
  lastTurnAt = Date.now();
@@ -1126,6 +1338,17 @@ function askedExcerpt(message) {
1126
1338
  messages: wake,
1127
1339
  statePath: DUTY_NUDGE_STATE,
1128
1340
  owner: `${RUNNER_ID}:${TURN + 1}`,
1341
+ // Has the recipient already read it? /peer — SINGULAR — is the only endpoint that serialises
1342
+ // deliveredUpTo (/peers does not, a gap that already cost one wrong diagnosis today). The
1343
+ // cursor is monotonic, so `>= id` means the message was handed over and there is nothing to
1344
+ // nudge about. Best-effort by design: any failure here leaves the nudge standing, because a
1345
+ // missed nudge is worse than a redundant one.
1346
+ isDelivered: async ({ id, recipient }) => {
1347
+ if (!recipient || !/^\d+$/.test(String(id))) return false;
1348
+ const r = await api(`/peer?session=${encodeURIComponent(recipient)}`).catch(() => null);
1349
+ const upTo = Number(r?.deliveredUpTo || 0);
1350
+ return upTo > 0 && upTo >= Number(id);
1351
+ },
1129
1352
  })
1130
1353
  : { items: [], targets: [], owner: "" };
1131
1354
  const claimedIds = new Set(dutyPlan.items.map(item => item.id));
@@ -1166,12 +1389,20 @@ function askedExcerpt(message) {
1166
1389
  // into every later turn — qwen's 85.7M tokens were 96.7% cached, i.e. replayed history. The
1167
1390
  // card that moved this wake decides: a different one starts a fresh CLI session, and the seat
1168
1391
  // is told so, because a fresh session remembers nothing and must be sent to its card.
1169
- const card = wakeForTurn.map(m => cardRef(m.text)).find(Boolean) || 0;
1392
+ // #7061: bound by SHAPE, not by position. `cardRef` alone took the earliest id in the wake
1393
+ // TEXT, and an order that opens with what shipped ("#7037 is merged as a01f629 … YOUR CARD:
1394
+ // #6983") binds the turn — its state sidecar, its card log, its run record — to a done card.
1395
+ const card = wakeCard(wakeForTurn, { session: SESSION });
1170
1396
  const fresh = card > 0 && card !== sessionCard;
1171
1397
  if (card) sessionCard = card;
1398
+ const cited = [...new Set(wakeForTurn.flatMap(m => cardRefs(m.text)))];
1172
1399
  const freshText = fresh
1173
1400
  ? `\n(FRESH SESSION for card #${card} — you are not the session that worked earlier cards and you remember none of them. Read your card first: relay_board with card:${card}.)\n`
1174
- : "";
1401
+ // A wake naming several cards used to leave the seat guessing which one the machine believed
1402
+ // — the prompt named two and committed to neither. Say it, even when the session continues.
1403
+ : (card && cited.length > 1
1404
+ ? `\n(This turn is card #${card} — the wake cites ${cited.length} cards; the rest are context.)\n`
1405
+ : "");
1175
1406
  const prompt = composedTurn({
1176
1407
  wakeText, ctxText, againText: againText + freshText + dutyNudgeDirective(dutyPlan),
1177
1408
  tailText: "\nAct on what's addressed to you, then end your turn.\n\n",
@@ -1179,7 +1410,19 @@ function askedExcerpt(message) {
1179
1410
  });
1180
1411
  const stopDutyNudgeWatcher = startDutyNudgeWatcher(dutyPlan, tStart);
1181
1412
  let ec;
1182
- try { ec = await runTurn(prompt, fresh, deliveryFails ? `${trigger} (redelivery)` : trigger); }
1413
+ const stateStep = !stateSkip("wake", card);
1414
+ try {
1415
+ ec = stateStep
1416
+ ? await stateTurn({
1417
+ card, trigger: deliveryFails ? `${trigger} (redelivery)` : trigger, assigners,
1418
+ // The observation is everything that CHANGED since the last step — the wake, the
1419
+ // broadcasts, the redelivery note, and any rejection the last step earned. None of it
1420
+ // may reach the preamble, or the prefix stops being byte-identical and the cache claim
1421
+ // dies quietly (§4.6).
1422
+ observation: [stateObservation, wakeText, ctxText, againText + freshText].filter(Boolean).join("\n"),
1423
+ })
1424
+ : await runTurn(prompt, fresh, deliveryFails ? `${trigger} (redelivery)` : trigger);
1425
+ }
1183
1426
  finally { stopDutyNudgeWatcher(); }
1184
1427
  const secs = Math.round((Date.now() - tStart) / 1000);
1185
1428
  let skippedNudges = [];
@@ -35,6 +35,9 @@ export const CARD_STEPS = {
35
35
  6668: { steps: ["S6-handoff"], autoClose: true },
36
36
  6317: { steps: ["S6-key-post", "S6-key-throw"], autoClose: false, recipe: "covered by in-app Drill Mode: open a real Workspace with a live terminal pane and exercise key dispatch in the terminal and composer" },
37
37
  6667: { steps: ["S4b", "S7"], autoClose: true },
38
+ // P9 (#6965): the Phase-1 handoff carries a WorkingState and it carries NO credit. Never
39
+ // auto-closes — with the flag off the step SKIPs, and a skip must not be mistaken for proof.
40
+ 6965: { steps: ["S4c"], autoClose: false, recipe: "set TRANTOR_STATE_HANDOFF=1 on a dogfood card, run `trantor drill`, and read S4c: the carried state must show 0 credited paths" },
38
41
  6587: { steps: ["S8"], autoClose: false, recipe: "needs live duty probe: operator removes the trantor/trantor-duty link, DMs the idle orchestrator, and checks for a socket nudge within 3 minutes" },
39
42
  6481: { steps: ["S9"], autoClose: true },
40
43
  6483: { steps: ["S10"], autoClose: false, recipe: "covered by in-app Drill Mode (#6800): operator checks Accounts with the CLI below the app minimum, then restores the CLI" },
@@ -346,6 +349,50 @@ step("S4 · handoff machine: warn → arm → fire → WRITTEN → successor cla
346
349
  }, { timeoutMs: 120_000, everyMs: 2_000 });
347
350
  if (recapped) PASS("first Stop recorded RECAPPED and cleared the net", recapped.states.map(s => s.state).join("→"));
348
351
  else FAIL("first Stop recorded RECAPPED and cleared the net", stamp() ? "stamp still present" : "no recapped state");
352
+
353
+ // ---------- S4c · the handoff carries a WorkingState, and it carries NO CREDIT ----------
354
+ // Rides S4's machine deliberately: the record above is a REAL handoff, written by the real Stop
355
+ // path and claimed by a real successor. Everything else about Phase 1 is proven against
356
+ // fixtures, and today taught us what that gap costs — a defect that survived two review rounds,
357
+ // five reviewers and 264 assertions was found only when something finally ran the live path.
358
+ //
359
+ // The load-bearing assertion is the second one. A derived state carries NO credit, because no
360
+ // gate ran: every path must be verified:false, so the successor has to re-earn its evidence
361
+ // before anything moves to done. If that ever came back true, the handoff path would be a
362
+ // laundering route for unverified work — a hole in the verified-done rule shaped exactly like a
363
+ // session boundary. P4's unit tests hold the same property (flipping it kills 4 assertions);
364
+ // this is that property on a real record.
365
+ //
366
+ // Flag OFF is the norm today, and then the correct outcome is a SKIP: the state block must be
367
+ // absent and the prose path untouched. A step that only passes with the feature on would go red
368
+ // for everyone until it ships, and a red nobody can act on is noise.
369
+ const carried = (() => {
370
+ try { return JSON.parse(readFileSync(handoffFile, "utf8")).state ?? null; }
371
+ catch { return null; }
372
+ })();
373
+ if (!carried) {
374
+ SKIP("S4c · handoff carries WorkingState",
375
+ "TRANTOR_STATE_HANDOFF is off — no state block on the record, and the prose path is unchanged. Turn the flag on for a dogfood card to exercise this.");
376
+ } else {
377
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- SAFETY: `carried` is JSON.parse of a handoff record read off disk — an untrusted I/O boundary, and this is its decode. A record written by an older schema, or a truncated write, can put anything in `files`; Object.keys on a non-object would throw and turn a real drill run into a crash rather than a verdict.
378
+ const files = (carried.files && typeof carried.files === "object") ? carried.files : {};
379
+ const paths = Object.keys(files);
380
+ const credited = paths.filter(p => files[p]?.verified === true);
381
+ if (credited.length === 0) {
382
+ PASS("S4c · the carried state grants NO credit (every path verified:false)",
383
+ `${paths.length} path(s), 0 credited`);
384
+ } else {
385
+ FAIL("S4c · the carried state grants NO credit (every path verified:false)",
386
+ `CREDITED: ${credited.join(", ")} — the handoff is laundering unverified work`);
387
+ }
388
+ // verify is a harness field; a derived state must not arrive claiming a gate ran either.
389
+ const v = carried.verify || {};
390
+ if (v.tested === true) FAIL("S4c · the carried state claims no gate ran", `verify.tested=true cmd=${v.cmd || "?"}`);
391
+ else PASS("S4c · the carried state claims no gate ran", `verify.tested=${v.tested ?? "absent"}`);
392
+
393
+ if (carried.schema_version) PASS("S4c · the carried state is schema-stamped", `v${carried.schema_version}`);
394
+ else FAIL("S4c · the carried state is schema-stamped", "no schema_version on the record");
395
+ }
349
396
  }
350
397
  }
351
398