trantor 0.18.50 → 0.18.51

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.50",
3
+ "version": "0.18.51",
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": {
@@ -469,10 +469,40 @@ async function parkSeat(reason, undelivered, resetHint = 0) {
469
469
  if (orch !== SESSION) await api("/send", { from: SESSION, to: orch, text, project: PROJ, kind: "alert" }).catch(() => {});
470
470
  }
471
471
  log(`\x1b[31mparked (${reason})${when ? ` — retrying after ${when}` : " — no reset time in the output; waiting for a restart"}\x1b[0m`);
472
+ // The two /send calls above are the whole escalation, and on 2026-09-09 that was not enough:
473
+ // the DUTY seat parked on a quota read, held 48 messages for 21.9 hours, and announced it over
474
+ // the very bus that had stopped moving, to an orchestrator that was idle and therefore could not
475
+ // receive it. The alarm for "the bus is stuck" cannot itself be a bus message. So park also
476
+ // rings a bell the operator can actually hear, out of band, once per park.
477
+ notifyOperator(`Trantor: ${SESSION} PARKED (${reason})`,
478
+ `${undelivered} message(s) held${when ? ` — retrying after ${when}` : ` — needs \`trantor up ${AGENT}\``}`);
472
479
  // No reset time means no timer can clear it: hold until the operator restarts the seat.
473
480
  return resetAt || Number.MAX_SAFE_INTEGER;
474
481
  }
475
482
 
483
+ /**
484
+ * Reach the operator on a channel that does not depend on the bus, the hub, or a live session.
485
+ * Best-effort and strictly non-fatal: a seat must never die because a notifier is missing.
486
+ * Silence-able with TRANTOR_NO_DESKTOP_NOTIFY=1 for headless boxes and test runs.
487
+ */
488
+ function notifyOperator(title, body) {
489
+ if (process.env.TRANTOR_NO_DESKTOP_NOTIFY === "1") return;
490
+ try {
491
+ // Always leave a durable trace first: a notification can be missed or suppressed, a file cannot.
492
+ // This is what `trantor doctor` reads, so the escalation survives a machine nobody was sitting at.
493
+ const alertsPath = join(homedir(), ".agent-bus", "alerts.jsonl");
494
+ appendFileSync(alertsPath, `${JSON.stringify({ ts: Date.now(), session: SESSION, title, body })}\n`);
495
+ } catch {}
496
+ try {
497
+ if (process.platform === "darwin") {
498
+ // osascript is present on every mac; no dependency to install and nothing to keep running.
499
+ const esc = (s) => String(s).replace(/["\\]/g, "\\$&");
500
+ spawnSync("osascript", ["-e", `display notification "${esc(body)}" with title "${esc(title)}"`],
501
+ { timeout: 5000, stdio: "ignore" });
502
+ }
503
+ } catch {}
504
+ }
505
+
476
506
  // The seat's own balance rows, for the #6131 read: a stalled turn that printed nothing on a seat
477
507
  // whose plan is spent is exhaustion, not a crash. Bounded and best-effort — a slow provider API
478
508
  // must never hold up the failure path, and an unreachable one just leaves the reason as it was.
@@ -887,7 +917,24 @@ function isRunnerSession(session) {
887
917
  return /^[a-z0-9_.-]+$/.test(label) && !label.startsWith("hub:");
888
918
  }
889
919
 
920
+ // A hub staleness alert describes a condition that was true for a moment: "#16909 has been
921
+ // UNDELIVERED for 2m — go nudge someone". Acting on it 22 hours later is meaningless, and the queue
922
+ // had no expiry, so on 2026-09-09 the duty seat's backlog became SELF-POISONING: the hub kept
923
+ // noticing undelivered mail and sending more alerts, duty could not work them off, and a restart
924
+ // faithfully redelivered 49 dead nudges and re-wedged the seat. 46 of those 49 were hub alerts, the
925
+ // oldest 22.1 hours old, every one describing a two-minute condition.
926
+ //
927
+ // So these EXPIRE. Deliberately narrow: only messages the HUB generated about staleness, never a
928
+ // message from a peer. A real contract is never dropped for being old — a seat that misses a
929
+ // teammate's request is the failure this bus exists to prevent, and no backlog is worth causing it.
930
+ const HUB_ALERT_TTL_MS = Number(process.env.TRANTOR_HUB_ALERT_TTL_MS || 30 * 60_000);
931
+ const isExpiredHubAlert = (m) =>
932
+ m?.from === "hub:duty" &&
933
+ Number.isFinite(m?.ts) &&
934
+ Date.now() - m.ts > HUB_ALERT_TTL_MS;
935
+
890
936
  function shouldWake(message) {
937
+ if (isExpiredHubAlert(message)) return false;
891
938
  if (isReceipt(message) || isStatusBroadcast(message)) return false;
892
939
  // #6134: the SENDER decides. `wake:false` says "this is context, not a contract" — it batches
893
940
  // into the next turn's prompt like a broadcast and never buys a CLI session of its own.
@@ -957,8 +1004,14 @@ function askedExcerpt(message) {
957
1004
  // broadcasts batched behind them. Restored from disk first: a runner that was killed mid-turn
958
1005
  // (or a machine that rebooted) still owes those messages, and the hub will never send them again.
959
1006
  const restored = loadPending();
1007
+ // Say what the restore SHED, not just what it kept. A queue that quietly halves itself on restart
1008
+ // is indistinguishable from one that lost real work, and this is the moment the expiry above
1009
+ // actually bites — a wedged seat comes back carrying only what still means something.
1010
+ const shed = restored.wake.filter(isExpiredHubAlert).length +
1011
+ restored.bcast.filter(isExpiredHubAlert).length;
960
1012
  let pendingWake = restored.wake.filter(shouldWake);
961
- let pendingBcast = restored.bcast.filter(m => !isReceipt(m) && !isStatusBroadcast(m));
1013
+ 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`);
962
1015
  let retryAt = 0; // 0 = deliver at the next opportunity
963
1016
  let deliveryFails = 0; // consecutive failed attempts at the SAME pending batch
964
1017
  if (pendingWake.length) log(`\x1b[33m${pendingWake.length} message(s) survived from a previous run — redelivering\x1b[0m`);
@@ -1183,6 +1236,21 @@ function askedExcerpt(message) {
1183
1236
  const parkReason = PARKING_REASONS.has(reason) ? reason : (lastTurnCut ? "time-box" : "api-error");
1184
1237
  if (PARKING_REASONS.has(reason) || deliveryFails >= 2) {
1185
1238
  retryAt = await parkSeat(parkReason, pendingWake.length, quotaReset);
1239
+ // A supervised seat does not have to sit parked until someone notices. RUNNER_PARK_MAX_MS
1240
+ // is set only by `trantor duty up`, which runs the seat under a launchd keepalive: past the
1241
+ // ceiling, exit and let the supervisor restart it clean — a fresh process re-reads auth and
1242
+ // redelivers the queue from disk, which is exactly what un-wedged the 2026-09-09 incident
1243
+ // when the operator finally ran `trantor duty up` by hand 21.9 hours late.
1244
+ // Unsupervised seats keep the old behaviour: exiting would just kill them for good.
1245
+ const parkMax = Number(process.env.RUNNER_PARK_MAX_MS || 0);
1246
+ if (parkMax > 0) {
1247
+ const wakeIn = Math.max(0, Math.min(retryAt - Date.now(), parkMax));
1248
+ log(`\x1b[33msupervised seat: exiting in ${Math.round(wakeIn / 1000)}s so the keepalive restarts it clean\x1b[0m`);
1249
+ setTimeout(() => {
1250
+ log("parked past the ceiling — exiting for the keepalive to relaunch");
1251
+ process.exit(0); // 0, not 1: this is a deliberate hand-off, not a crash
1252
+ }, wakeIn).unref?.();
1253
+ }
1186
1254
  await notifyAssigners(assigners,
1187
1255
  `⛔ your contract is PARKED on ${SESSION} (${parkReason}) — not retrying · asked: "${asked}"`);
1188
1256
  lastTurnAt = Date.now();
package/bin/doctor.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // Checks: runtime, hub, plugin, each CLI (installed? wired? AUTHENTICATED?), API keys,
4
4
  // quota profile, optional Scrooge brain. Prints a checklist with copy-paste fixes.
5
5
  // node bin/doctor.mjs
6
- import { readFileSync, existsSync } from "node:fs";
6
+ import { readFileSync, readdirSync, existsSync } from "node:fs";
7
7
  import { join, dirname } from "node:path";
8
8
  import { homedir } from "node:os";
9
9
  import { execSync } from "node:child_process";
@@ -304,6 +304,66 @@ prof?.providers && Object.keys(prof.providers).length
304
304
  ? ok(`quota profile set (${Object.entries(prof.providers).map(([k, v]) => `${k}=${v.plan}`).join(", ")})`)
305
305
  : 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
306
 
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.
314
+ section("the fleet (is it actually running?)");
315
+ {
316
+ const busDir = join(H, ".agent-bus");
317
+ // 1. Undelivered queues. crew-runner persists pending-<agent>-<project>.json on every failed
318
+ // delivery and unlinks it when the queue drains, so a file with an old head means mail is
319
+ // stuck for that seat — whatever its process table says.
320
+ let stuck = 0;
321
+ const abandonedSeats = [];
322
+ try {
323
+ for (const f of readdirSync(busDir).filter(n => n.startsWith("pending-") && n.endsWith(".json"))) {
324
+ const j = read(join(busDir, f));
325
+ const held = [...(j?.wake || []), ...(j?.bcast || [])];
326
+ if (!held.length) continue;
327
+ const stamps = held.map(m => m?.ts).filter(Number.isFinite);
328
+ const oldest = stamps.length ? Math.min(...stamps) : j?.ts;
329
+ const hours = (Date.now() - oldest) / 3.6e6;
330
+ 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.
336
+ const abandoned = hours >= 24 * 7;
337
+ if (hours >= 1 && !abandoned) {
338
+ stuck++;
339
+ warn(`${seat}: ${held.length} message(s) undelivered, oldest ${hours.toFixed(1)}h old — mail is not moving`,
340
+ `the runner parks on quota/api failure and only a restart un-parks it: trantor up ${seat.split("-")[0]} (duty: trantor duty up)`);
341
+ } else if (abandoned) {
342
+ abandonedSeats.push(`${seat} (${(hours / 24).toFixed(0)}d)`);
343
+ } else {
344
+ note(`${seat}: ${held.length} queued, oldest ${hours.toFixed(1)}h — within the retry ladder`);
345
+ }
346
+ }
347
+ if (abandonedSeats.length) {
348
+ note(`${abandonedSeats.length} abandoned queue(s) older than a week: ${abandonedSeats.join(", ")} — leftovers from finished work, safe to delete`);
349
+ }
350
+ if (!stuck) ok("no live seat is sitting on undelivered mail");
351
+ } catch { note(`no bus directory at ${busDir} yet — nothing has run`); }
352
+
353
+ // 2. Park alerts. notifyOperator appends one line per park, so a park that happened while nobody
354
+ // was at the machine is still visible here afterwards — the point of writing it to disk.
355
+ try {
356
+ const alerts = readFileSync(join(busDir, "alerts.jsonl"), "utf8").trim().split("\n").filter(Boolean);
357
+ const recent = alerts.map(l => { try { return JSON.parse(l); } catch { return null; } })
358
+ .filter(a => a && Date.now() - a.ts < 24 * 3.6e6);
359
+ if (recent.length) {
360
+ const last = recent[recent.length - 1];
361
+ warn(`${recent.length} seat park alert(s) in the last 24h — most recent: ${last.title}`,
362
+ `read them: tail ~/.agent-bus/alerts.jsonl — then restart the seat named above`);
363
+ } else ok("no seat has parked in the last 24h");
364
+ } catch { ok("no seat has parked in the last 24h"); }
365
+ }
366
+
307
367
  say(issues ? `\n${issues} issue(s) — fix the → lines above, then re-run the doctor.` : "\nAll clear — open a claude session in any project and say: \"fire up the crew\".");
308
368
  // Must come BEFORE the exit — process.exit() here truncated the report entirely.
309
369
  if (JSON_MODE) console.log(JSON.stringify({ ...REPORT, issueCount: issues }));
package/bin/duty.mjs CHANGED
@@ -245,6 +245,14 @@ if (cmd === "up") {
245
245
  const env = (() => {
246
246
  const e = { RELAY_URL: hub, RUNNER_RULES: RULES, CREW_KICKOFF: KICKOFF,
247
247
  RUNNER_DUTY_NUDGES: "1",
248
+ // A parked seat waits for `trantor up`. That is right for a CODE seat — burning a
249
+ // plan re-sending the same contract helps nobody (#6270). It is wrong for DUTY,
250
+ // which is the seat every other seat's liveness runs through: on 2026-09-09 it
251
+ // parked on a quota read and sat holding 48 messages for 21.9 hours while the
252
+ // crew finished a night's work nobody gated. Duty is the one seat under a launchd
253
+ // keepalive, so it does not need to sit there — it can exit and let the supervisor
254
+ // bring it back clean, which also re-reads auth. 15 minutes, then hand over.
255
+ RUNNER_PARK_MAX_MS: "900000",
248
256
  RUNNER_TITLE: "Trantor Duty Agent", RUNNER_ABOUT: ABOUT,
249
257
  // launchd starts jobs with a MINIMAL Path — the resurrected seat could not find
250
258
  // `claude` and every turn died exit 127 "missing-cli" (found live 2026-08-31,
@@ -360,6 +368,31 @@ if (cmd === "down") {
360
368
  process.exit(0);
361
369
  }
362
370
 
371
+ /**
372
+ * Is duty actually moving mail? Read from the runner's own on-disk queue rather than trusting the
373
+ * process table. `pending-<agent>-<project>.json` is written on every failed delivery and deleted
374
+ * when the queue drains, so its existence means messages are held and its oldest entry says for how
375
+ * long. STALL_HOURS is deliberately well past the runner's own 15-minute retry ceiling: anything
376
+ * older than that is not a retry in progress, it is a parked seat nobody restarted.
377
+ */
378
+ const STALL_HOURS = 1;
379
+ function dutyHealth() {
380
+ const f = join(BUS, `pending-${AGENT}-trantor-duty.json`);
381
+ try {
382
+ const j = JSON.parse(readFileSync(f, "utf8"));
383
+ const wake = Array.isArray(j.wake) ? j.wake : [];
384
+ const bcast = Array.isArray(j.bcast) ? j.bcast : [];
385
+ const held = wake.length + bcast.length;
386
+ if (!held) return { held: 0, stalled: false, oldestHours: "0.0" };
387
+ const stamps = [...wake, ...bcast].map(m => m?.ts).filter(Number.isFinite);
388
+ const oldest = stamps.length ? Math.min(...stamps) : j.ts;
389
+ const hours = (Date.now() - oldest) / 3.6e6;
390
+ return { held, stalled: hours >= STALL_HOURS, oldestHours: hours.toFixed(1) };
391
+ } catch {
392
+ return { held: 0, stalled: false, oldestHours: "0.0" }; // no queue file = nothing held
393
+ }
394
+ }
395
+
363
396
  // status
364
397
  {
365
398
  const pid = alivePid();
@@ -370,7 +403,41 @@ if (cmd === "down") {
370
403
  console.log(" whether a seat is still alive, and clears away dead runners. It never writes code");
371
404
  console.log(" and never edits your project files. Stop it with: trantor duty down");
372
405
  console.log("");
373
- console.log(pid ? `RUNNING (pid ${pid}) as ${SESSION}` : "NOT running");
406
+ // A pid is not health. This line used to read `pid ? "RUNNING" : "NOT running"`, and on 2026-09-09
407
+ // it said RUNNING for a seat whose last successful turn was 21.8 hours earlier and which was
408
+ // holding 48 undelivered messages, the oldest 21.9 hours old — the runner had parked itself
409
+ // waiting on a quota reset and only `trantor up` un-parks it. Nothing noticed, because the one
410
+ // command whose job is to answer "is duty working" was answering "does a process exist".
411
+ //
412
+ // The irony worth keeping: ten lines below, this same function already refuses to assume the hub
413
+ // feed is wired, on the grounds that "a running seat the hub isn't feeding looks identical to a
414
+ // working one from the outside". That rigour was applied to the hub and not to the seat itself.
415
+ //
416
+ // The queue is the honest signal because it survives the process: the runner persists it to
417
+ // pending-<agent>-<project>.json on every failed delivery, so a backlog with an old head means
418
+ // messages are not moving no matter what the process table says.
419
+ const health = dutyHealth();
420
+ if (!pid) {
421
+ console.log("NOT running");
422
+ // A backlog matters MORE when the seat is down, not less: those messages have nobody to deliver
423
+ // them and no retry ladder running. Reporting it only in the pid branch was the first version
424
+ // of this fix, and it hid exactly the case that needs saying out loud.
425
+ if (health.held) {
426
+ console.log(` \x1b[31mand ${health.held} undelivered message(s) are held on disk, oldest ${health.oldestHours}h old\x1b[0m`);
427
+ console.log(` → nothing will deliver them until the seat is back: trantor duty up`);
428
+ } else {
429
+ console.log(" queue empty — nothing is waiting");
430
+ }
431
+ }
432
+ else if (health.stalled) {
433
+ console.log(`\x1b[31mSTALLED\x1b[0m (pid ${pid}) as ${SESSION} — the process is up but mail is not moving`);
434
+ console.log(` holding ${health.held} undelivered message(s), oldest ${health.oldestHours}h old`);
435
+ console.log(` → the runner parks on quota/api failure and only a restart un-parks it: trantor duty up`);
436
+ } else if (health.held) {
437
+ console.log(`RUNNING (pid ${pid}) as ${SESSION} — draining ${health.held} queued message(s), oldest ${health.oldestHours}h old`);
438
+ } else {
439
+ console.log(`RUNNING (pid ${pid}) as ${SESSION} — queue empty`);
440
+ }
374
441
  console.log(existsSync(DUTY_PLIST)
375
442
  ? `keepalive: installed (${DUTY_LABEL}${process.platform === "darwin" ? (dutyLoaded() ? ", loaded" : ", not loaded in this session") : ""}) — launchd relaunches the seat after a crash or reboot`
376
443
  : "keepalive: NOT installed — a crash or reboot leaves the seat down (trantor duty up installs it)");
@@ -6,7 +6,7 @@ import { writeFileSync, readFileSync, existsSync, mkdirSync, readdirSync } from
6
6
  import { join, basename } from "node:path";
7
7
  import { homedir, hostname } from "node:os";
8
8
  import { execSync } from "node:child_process";
9
- import { spawnBaton, handoffMode, resolveHandoffSurface } from "../hooks/lib/handoff.mjs";
9
+ import { spawnBaton, handoffMode, resolveHandoffSurface, attachState, resolveSeat, resolveHandoffCard } from "../hooks/lib/handoff.mjs";
10
10
  import { handoffDir } from "../lib/project.mjs";
11
11
 
12
12
  const baton = process.argv.includes("--baton");
@@ -63,6 +63,12 @@ if (latest) {
63
63
  // carries the same interface the hooks-side records have: transcript_path ("" — the summary IS
64
64
  // the model's own words), mode (attended|unattended, #5648).
65
65
  const rec = { id: `${name}-${stamp}`, project, projectName: name, machine: hostname(), trigger: baton ? "manual-baton" : "manual-skill", stamp: Number(stamp) || 0, summary: summary.trim() || "(empty)", transcript_path: "", mode: handoffMode(name), gitStatus: git, consumed: false, states: [{ state: "written", ts: Number(stamp) || 0, by: baton ? "manual-baton" : "manual-skill" }] };
66
+ // The structured working state rides beside the prose (TDD §4.5), dark behind
67
+ // TRANTOR_STATE_HANDOFF. The manual path gets it for the same reason the hook path does: this
68
+ // record is what the successor loads, and `summary` here IS the model's own handoff — the richest
69
+ // STATE block there is.
70
+ const seat = resolveSeat(name);
71
+ attachState(rec, { project: name, seat, card: resolveHandoffCard({ projectName: name, seat }), worktree: project });
66
72
  const file = join(dir, `${rec.id}.json`);
67
73
  writeFileSync(file, JSON.stringify(rec, null, 2));
68
74
  console.log(`handoff saved: ${file}`);
@@ -18,7 +18,13 @@ import { fileURLToPath } from "node:url";
18
18
  import { deriveSubagentManifest } from "../../lib/subagent-manifest.mjs";
19
19
  import { signedPost } from "./api.mjs";
20
20
  import { loadAutonomy, resolveAutonomy } from "../../lib/autonomy.mjs";
21
- import { resolveProject, orchSessionsPath } from "../../lib/project.mjs";
21
+ import { resolveProject, orchSessionsPath, hostId } from "../../lib/project.mjs";
22
+ // Trantor State (TDD §4.5). Dark behind TRANTOR_STATE_HANDOFF: these are imported unconditionally
23
+ // because they are pure modules with no side effects at load, and a lazy import would make
24
+ // attachState async on a path that is deliberately synchronous.
25
+ import { statePath, readState } from "../../lib/state/store.mjs";
26
+ import { stateError } from "../../lib/state/schema.mjs";
27
+ import { deriveState } from "../../lib/state/derive.mjs";
22
28
 
23
29
  // Writer and reader MUST resolve the same directory — see lib/project.mjs busDir(). This used to
24
30
  // honour only RELAY_DATA_DIR while the reader honoured neither override.
@@ -448,6 +454,109 @@ export function capSummary(text, cap = 4096) {
448
454
  return head + elide + tail;
449
455
  }
450
456
 
457
+ // ---------------------------------------------------------------------------------------------
458
+ // Trantor State — the structured field on the record (TDD §4.5). `summary` keeps being written
459
+ // exactly as it is today; `state` rides beside it, validated on write and NEVER capped. That is
460
+ // the whole fix for #6528: capSummary's mid-string elision ate the STATE section of the prose, and
461
+ // the structured field cannot lose a member because the lossy operation is not applied to it.
462
+ // ---------------------------------------------------------------------------------------------
463
+
464
+ /** Dark by default. The prose path is untouched either way; this flag only decides whether the
465
+ * structured field is built and rendered (TDD §4.5, "Fallback"). */
466
+ export function stateHandoffEnabled(env = process.env) {
467
+ return ["1", "true", "on", "yes"].includes(String(env.TRANTOR_STATE_HANDOFF || "").toLowerCase());
468
+ }
469
+
470
+ /** The bus id of the seat writing this handoff — the same resolution sessionstart.mjs uses, so a
471
+ * sidecar written under the runner's seat name is the one this path reads back. */
472
+ export function resolveSeat(projectName, env = process.env) {
473
+ return env.RELAY_SESSION || (env.RELAY_AGENT ? `${env.RELAY_AGENT}:${projectName}` : `${hostId()}:${projectName}`);
474
+ }
475
+
476
+ /**
477
+ * Which card this handoff belongs to. `TRANTOR_CARD` wins — the crew runner knows the answer for
478
+ * certain and a lookup cannot beat being told. Otherwise ask the hub for this seat's newest open
479
+ * card, on the same 2s best-effort budget as the verify-gates fetch: a hub that is down costs the
480
+ * handoff a card number, never the handoff.
481
+ */
482
+ export function resolveHandoffCard({ projectName, seat, env = process.env } = {}) {
483
+ const told = Number(env.TRANTOR_CARD);
484
+ if (Number.isInteger(told) && told > 0) return told;
485
+ try {
486
+ const out = execSync(`curl -s --max-time 2 ${JSON.stringify(relayUrl() + "/tasks?project=" + encodeURIComponent(projectName))}`, { encoding: "utf8", timeout: 2500 });
487
+ const tasks = JSON.parse(out).tasks || [];
488
+ const mine = tasks
489
+ .filter(t => t && Number.isInteger(t.id) && t.assignee === seat && ["doing", "testing"].includes(t.status))
490
+ .sort((a, b) => (a.status === b.status ? (b.updated || b.ts || 0) - (a.updated || a.ts || 0) : a.status === "doing" ? -1 : 1));
491
+ return mine.length ? mine[0].id : 0;
492
+ } catch { return 0; }
493
+ }
494
+
495
+ /**
496
+ * Attach the structured working state to a handoff record, from two sources in order:
497
+ * 1. the sidecar, when one exists (Phase 2a and after) — read, migrated, validated;
498
+ * 2. derived from git + the model's own STATE block, when none does (every Phase-1 handoff).
499
+ *
500
+ * Invalid or underivable state attaches `null` and logs. It NEVER blocks a handoff: a session at
501
+ * the context wall losing its baton because a state object would not validate is a far worse
502
+ * failure than a successor reading prose, which is exactly what it read before this field existed.
503
+ * @returns {object|null} the attached state
504
+ */
505
+ export function attachState(rec, { project, seat, card, worktree, env = process.env } = {}) {
506
+ if (!stateHandoffEnabled(env)) return null;
507
+ try {
508
+ const name = project || rec?.projectName || "";
509
+ const who = seat || resolveSeat(name, env);
510
+ const no = Number.isInteger(card) ? card : 0;
511
+ const cwd = worktree || rec?.project || "";
512
+
513
+ let state = null;
514
+ const sidecar = statePath(who, no, name);
515
+ if (sidecar && existsSync(sidecar)) {
516
+ const r = readState(who, no, { project: name, cwd, recover: false });
517
+ if (!r.ok) throw new Error(`sidecar rejected: ${r.code} at ${r.at} — ${r.message}`);
518
+ state = r.state;
519
+ } else {
520
+ state = deriveState({ project: name, seat: who, card: no, worktree: cwd, handoffText: rec?.summary || "" });
521
+ }
522
+
523
+ const why = state ? stateError(state) : "no state could be derived";
524
+ if (why) throw new Error(why);
525
+ rec.state = state;
526
+ return state;
527
+ } catch (e) {
528
+ process.stderr.write(`[trantor] handoff state skipped: ${e?.message || e}\n`);
529
+ rec.state = null;
530
+ return null;
531
+ }
532
+ }
533
+
534
+ /** The state as the successor reads it: one compact block, bounded by the schema's own caps, with
535
+ * the absence of credit stated rather than implied. */
536
+ export function renderStateBlock(state) {
537
+ const line = (items) => items.map(i => `${i.id} ${i.text}${i.paths?.length ? ` [${i.paths.join(", ")}]` : ""}`).join("; ");
538
+ const rows = [];
539
+ if (state?.task) rows.push(`task: ${state.task}`);
540
+ for (const [list, label] of [["done", "done"], ["in_flight", "in flight"], ["next", "next"], ["blockers", "blockers"]]) {
541
+ const items = state?.[list] || [];
542
+ if (!items.length) continue;
543
+ const more = list === "done" && state.done_count ? ` (+${state.done_count} compacted)` : "";
544
+ rows.push(`${label} (${items.length}${more}): ${line(items)}`);
545
+ }
546
+ const files = state?.files || {};
547
+ const paths = Object.keys(files);
548
+ const verified = paths.filter(p => files[p].verified === true);
549
+ if (paths.length) rows.push(`files: ${paths.length} touched, ${verified.length} verified${verified.length ? ` — ${verified.join(", ")}` : ""}`);
550
+ const verify = Object.entries(state?.verify || {});
551
+ if (verify.length) rows.push(`verify: ${verify.map(([k, v]) => `${k}=${v}`).join(" ")}`);
552
+ if (!rows.length) return ""; // nothing to render is not a block with a warning in it
553
+ if (!verified.length) {
554
+ rows.push("NO PATH IS VERIFIED HERE — no gate ran at the handoff. Nothing in this block is evidence: re-earn it before you move anything to done.");
555
+ }
556
+ if (state.notes) rows.push(`notes: ${state.notes}`);
557
+ return rows.join("\n");
558
+ }
559
+
451
560
  // How fresh a model-authored handoff must be before an automatic digest DEFERS to it: 15 minutes.
452
561
  // Older than that, the state it describes has likely moved on — compose fresh.
453
562
  const FRESH_HANDOFF_SEC = 15 * 60;
@@ -564,6 +673,11 @@ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summa
564
673
  // The §5 machine's ledger: every transition appends here via appendHandoffState.
565
674
  states: [{ state: "written", ts: Number(stamp) || 0, by: sessionId || "" }],
566
675
  };
676
+ // The structured field (TDD §4.5), dark behind TRANTOR_STATE_HANDOFF. It is attached AFTER the
677
+ // record is built because it reads `summary` — the model's own STATE block is one of its two
678
+ // sources — and BEFORE the write, so the field lands in the same file the successor loads.
679
+ const seat = resolveSeat(projectName);
680
+ attachState(record, { project: projectName, seat, card: resolveHandoffCard({ projectName, seat }), worktree: projectDir });
567
681
  const file = join(HANDOFF_DIR, `${record.id}.json`);
568
682
  writeFileSync(file, JSON.stringify(record, null, 2));
569
683
  supersedeOlderHandoffs(projectName, record.id);
@@ -14,6 +14,7 @@ import { fileURLToPath } from "node:url";
14
14
  import { resolveProject, hostId, resolveHubInfo, knownProjects, nonSeatReason, handoffDir, readOrchSession, writeOrchSession } from "../lib/project.mjs";
15
15
  import { formatSubagentManifest } from "../lib/subagent-manifest.mjs";
16
16
  import { updateAvailable, maybeNotifyDesktop, readConfig } from "./lib/update-check.mjs";
17
+ import { renderStateBlock } from "./lib/handoff.mjs";
17
18
  import { maybeCheckBalances } from "./lib/balance-check.mjs";
18
19
  import { getJSON, signedGet, signedPost, loadIdentity } from "./lib/api.mjs";
19
20
  import { ledgerPaths, ensureStart, anchorCursor, writeCursor } from "./lib/inbox-ledger.mjs";
@@ -569,6 +570,15 @@ try {
569
570
  }
570
571
  additionalContext += `\n`;
571
572
  }
573
+ // The structured working state (TDD §4.5), AFTER the recap instruction and above the prose: it
574
+ // is bounded by the schema's own caps, so unlike the summary it cannot have lost a member to an
575
+ // elision (#6528). A record without one — every handoff until the flag is on — renders nothing
576
+ // and the successor sees exactly today's prose handoff.
577
+ const stateBlock = renderStateBlock(handoff.state);
578
+ if (stateBlock) {
579
+ additionalContext += `## Working state (structured, card #${sanitize(String(handoff.state.card || 0))}, turn ${sanitize(String(handoff.state.cursor?.turn ?? 0))})\n`;
580
+ additionalContext += `${sanitize(stateBlock)}\n\n`;
581
+ }
572
582
  additionalContext += `## Handoff summary\n${sanitize(capHandoffSummary(handoff))}\n`;
573
583
  if (handoff.gitStatus) additionalContext += `\n## Git working-tree at handoff\n\`\`\`\n${sanitize(handoff.gitStatus)}\n\`\`\`\n`;
574
584
  // Sub-agent manifest: LIVE-primary, snapshot-as-fallback. The prior session may have had