trantor 0.18.52 → 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.52",
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/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,8 +24,8 @@ import {
24
24
  } from "../lib/classify-failure.mjs";
25
25
  import { capWake, capBcast, pickLessons, composePrompt } from "./crew-payload.mjs";
26
26
  import {
27
- cardRef, carriesWork, parseTurnTokens, parseResetAt, reasonWithBalances, 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,
@@ -611,10 +611,33 @@ const STATE_MODE = (() => {
611
611
  mkdirSync(join(homedir(), ".agent-bus"), { recursive: true, mode: 0o700 });
612
612
  writeFileSync(STATE_SCHEMA_FILE, JSON.stringify(TURN_RESULT_SCHEMA), { mode: 0o600 });
613
613
  } catch (e) { log(`\x1b[33mstate mode OFF — could not write ${STATE_SCHEMA_FILE}: ${e.message}\x1b[0m`); return false; }
614
- log(`\x1b[36mTrantor State: ASSEMBLE mode ON for this seat (schema ${STATE_SCHEMA_FILE})\x1b[0m`);
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`);
615
621
  return true;
616
622
  })();
617
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
+
618
641
  // The PREAMBLE, and it is the whole cost claim in one constant: the bytes before STATE_DELIM must
619
642
  // be identical on every step or provider prefix caching never engages and the curve stays O(T).
620
643
  // So it is computed ONCE, from things that do not vary per turn — no clock, no turn number, no
@@ -1201,6 +1224,9 @@ function askedExcerpt(message) {
1201
1224
  let deliveryFails = 0; // consecutive failed attempts at the SAME pending batch
1202
1225
  if (pendingWake.length) log(`\x1b[33m${pendingWake.length} message(s) survived from a previous run — redelivering\x1b[0m`);
1203
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");
1204
1230
  const ec0 = await runTurn(composedTurn({ base: KICKOFF, lessons: pickLessons(LESSONS_RAW, "") }), true, "kickoff");
1205
1231
  if (ec0) await reportFailure(ec0, "kickoff", pendingWake.length); // a failed kickoff = the "fired up, died, nobody knew" case
1206
1232
  let lastTurnAt = Date.now();
@@ -1211,6 +1237,7 @@ function askedExcerpt(message) {
1211
1237
  // pulse first: a due mission beat runs even on a silent bus. Measured from the END of the
1212
1238
  // last turn, so a long turn doesn't stack an immediate pulse on top of itself.
1213
1239
  if (PULSE_MS && Date.now() - lastTurnAt >= PULSE_MS) {
1240
+ stateSkip("pulse");
1214
1241
  const ecp = await runTurn(composedTurn({ base: PULSE_PROMPT + "\n\n", rulesText: RULES, lessons: pickLessons(LESSONS_RAW, PULSE_PROMPT) }), false, "pulse");
1215
1242
  if (ecp) await reportFailure(ecp, "pulse"); else await reportHealthy();
1216
1243
  lastTurnAt = Date.now();
@@ -1362,12 +1389,20 @@ function askedExcerpt(message) {
1362
1389
  // into every later turn — qwen's 85.7M tokens were 96.7% cached, i.e. replayed history. The
1363
1390
  // card that moved this wake decides: a different one starts a fresh CLI session, and the seat
1364
1391
  // is told so, because a fresh session remembers nothing and must be sent to its card.
1365
- 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 });
1366
1396
  const fresh = card > 0 && card !== sessionCard;
1367
1397
  if (card) sessionCard = card;
1398
+ const cited = [...new Set(wakeForTurn.flatMap(m => cardRefs(m.text)))];
1368
1399
  const freshText = fresh
1369
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`
1370
- : "";
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
+ : "");
1371
1406
  const prompt = composedTurn({
1372
1407
  wakeText, ctxText, againText: againText + freshText + dutyNudgeDirective(dutyPlan),
1373
1408
  tailText: "\nAct on what's addressed to you, then end your turn.\n\n",
@@ -1375,7 +1410,7 @@ function askedExcerpt(message) {
1375
1410
  });
1376
1411
  const stopDutyNudgeWatcher = startDutyNudgeWatcher(dutyPlan, tStart);
1377
1412
  let ec;
1378
- const stateStep = STATE_MODE && !breakerTripped && card > 0;
1413
+ const stateStep = !stateSkip("wake", card);
1379
1414
  try {
1380
1415
  ec = stateStep
1381
1416
  ? await stateTurn({
@@ -206,6 +206,15 @@ function isToolResult(row) {
206
206
  return c.some(part => part && typeof part === "object" && part.type === "tool_result");
207
207
  }
208
208
 
209
+ /** The rows a MEASUREMENT gate may read: every row except one the time box cut short (#7135).
210
+ * A cut row is honest — recordStep keeps its absent cost null and its cache_read 0 — and §8.7
211
+ * needs it to find the cut, but it measured nothing: its 0 is not a cache miss and its null is
212
+ * not a price. Gates 3, 4 and 5 filter through here so one boxed turn neither reads as preamble
213
+ * drift nor voids a whole run's cost basis; §8.6 and §8.7 keep the full record. */
214
+ export function measurableSteps(steps) {
215
+ return (Array.isArray(steps) ? steps : []).filter(s => s?.cut !== true);
216
+ }
217
+
209
218
  /** Sum rows into the totals the ratio is computed from. `cost_usd` stays null unless EVERY row
210
219
  * carried one: a partial sum compared against a full one is a fake ratio. */
211
220
  export function totals(rows) {
@@ -299,22 +308,25 @@ export function baselineMarkdown({ project, card, transcript, rows, capturedAt,
299
308
  * assembled preamble drifted between turns, prefix caching never engaged, and the cost claim is
300
309
  * void even when the totals happen to look fine — which they can, on a short run, for entirely
301
310
  * unrelated reasons. The first step is exempt because there is nothing before it to have cached.
311
+ * Cut rows are excluded before any of this (measurableSteps): a boxed turn's cache_read 0 is an
312
+ * absence of measurement, not a miss.
302
313
  */
303
314
  export function checkCache(steps) {
304
- if (!Array.isArray(steps) || steps.length < 2) {
305
- return { ok: false, code: "TOO_FEW_STEPS", checked: steps?.length ?? 0, violations: [], message: "fewer than two steps: the cache property has no second turn to hold on and was not observed" };
315
+ const rows = measurableSteps(steps);
316
+ if (rows.length < 2) {
317
+ return { ok: false, code: "TOO_FEW_STEPS", checked: rows.length, violations: [], message: "fewer than two steps: the cache property has no second turn to hold on and was not observed" };
306
318
  }
307
319
  const violations = [];
308
- for (let i = 1; i < steps.length; i++) {
309
- const read = tokens(steps[i].cache_read);
310
- if (read <= 0) violations.push({ turn: steps[i].turn ?? i + 1, cache_read: read });
320
+ for (let i = 1; i < rows.length; i++) {
321
+ const read = tokens(rows[i].cache_read);
322
+ if (read <= 0) violations.push({ turn: rows[i].turn ?? i + 1, cache_read: read });
311
323
  }
312
324
  return {
313
325
  ok: violations.length === 0, code: violations.length ? "CACHE_MISS" : null,
314
- checked: steps.length - 1, violations,
326
+ checked: rows.length - 1, violations,
315
327
  message: violations.length
316
- ? `${violations.length} of ${steps.length - 1} steps read 0 cached tokens — the preamble drifted, prefix caching never engaged, and the ≥${COST_FACTOR}× claim is VOID regardless of the totals`
317
- : `all ${steps.length - 1} steps after the first read cached prefix tokens`,
328
+ ? `${violations.length} of ${rows.length - 1} steps read 0 cached tokens — the preamble drifted, prefix caching never engaged, and the ≥${COST_FACTOR}× claim is VOID regardless of the totals`
329
+ : `all ${rows.length - 1} steps after the first read cached prefix tokens`,
318
330
  };
319
331
  }
320
332
 
@@ -327,11 +339,16 @@ export function checkCache(steps) {
327
339
  * problem worth knowing about; a bench that only knows how to agree is not a gate. When no row
328
340
  * carries a price the ratio is computed on total tokens and says so in `basis`, because tokens are
329
341
  * a real measurement and a price derived from a rate card we did not record is not.
342
+ *
343
+ * Cut rows are excluded first (measurableSteps): one boxed turn's null price used to void
344
+ * `cost_usd` for the ENTIRE run through totals(), silently downgrading a measured-dollar result
345
+ * to an estimate — the unfalsifiability the PRD forbids (#7135).
330
346
  */
331
347
  export function costGate(steps, baselineRows, opts = {}) {
332
348
  const factor = opts.factor ?? COST_FACTOR;
333
349
  const minTurns = opts.minTurns ?? MIN_TURNS;
334
- const run = totals(steps || []);
350
+ const measured = measurableSteps(steps);
351
+ const run = totals(measured);
335
352
  const base = totals(baselineRows || []);
336
353
  const out = { ok: false, code: null, run, base, turns: run.turns, slope: null, slope_ratio: null, ratio: null, basis: null, message: "" };
337
354
 
@@ -353,12 +370,12 @@ export function costGate(steps, baselineRows, opts = {}) {
353
370
  out.basis = priced ? "cost_usd" : "weighted";
354
371
  const weigh = (r) => tokens(r.input) * weights.input + tokens(r.cache_read) * weights.cache_read
355
372
  + tokens(r.cache_creation) * weights.cache_creation + tokens(r.output) * weights.output;
356
- const perTurn = steps.map(s => (priced ? finite(s.cost_usd) : weigh(s)));
373
+ const perTurn = measured.map(s => (priced ? finite(s.cost_usd) : weigh(s)));
357
374
  out.slope = fitSlope(perTurn);
358
375
  const mean = perTurn.reduce((a, b) => a + (b ?? 0), 0) / perTurn.length;
359
376
  out.slope_ratio = out.slope === null || mean === 0 ? null : Math.abs(out.slope) * (perTurn.length - 1) / mean;
360
377
 
361
- const runTotal = priced ? run.cost_usd : steps.reduce((a, r) => a + weigh(r), 0);
378
+ const runTotal = priced ? run.cost_usd : measured.reduce((a, r) => a + weigh(r), 0);
362
379
  const baseTotal = priced ? base.cost_usd : (baselineRows || []).reduce((a, r) => a + weigh(r), 0);
363
380
  out.ratio = runTotal > 0 ? baseTotal / runTotal : null;
364
381
 
@@ -381,18 +398,23 @@ export function costGate(steps, baselineRows, opts = {}) {
381
398
  * that quietly re-read half the worktree), and the action check catches the cheaper failure the
382
399
  * bound cannot see: one small `Read` that says the state block did not carry, so the seat went
383
400
  * back to the filesystem to find out where it was.
401
+ *
402
+ * Cut rows are excluded first (measurableSteps): a boxed turn's zeroed measurements are not a
403
+ * recovery step — if the row after a disturbance was cut, this check reports NO_NEXT_STEP rather
404
+ * than passing on a turn that never ran.
384
405
  */
385
406
  export function disturbanceCheck(steps, opts = {}) {
386
- const marks = (steps || []).map((s, i) => (s.disturbed ? i : -1)).filter(i => i >= 0);
407
+ const rows = measurableSteps(steps);
408
+ const marks = rows.map((s, i) => (s.disturbed ? i : -1)).filter(i => i >= 0);
387
409
  if (!marks.length) {
388
410
  return { ok: false, code: "NO_DISTURBANCE", cases: [], message: "no step is marked `disturbed` — §8.5 was never exercised on this run, so it did not pass it" };
389
411
  }
390
412
  const factor = opts.factor ?? DISTURB_INPUT_FACTOR;
391
413
  const cases = [];
392
414
  for (const i of marks) {
393
- const next = steps[i + 1];
394
- if (!next) { cases.push({ at: steps[i].turn ?? i + 1, ok: false, code: "NO_NEXT_STEP", detail: "the run ended at the disturbance — the recovery step was never taken" }); continue; }
395
- const priorMedian = median(steps.slice(0, i + 1).map(s => tokens(s.input)));
415
+ const next = rows[i + 1];
416
+ if (!next) { cases.push({ at: rows[i].turn ?? i + 1, ok: false, code: "NO_NEXT_STEP", detail: "the run ended at the disturbance — the recovery step was never taken" }); continue; }
417
+ const priorMedian = median(rows.slice(0, i + 1).map(s => tokens(s.input)));
396
418
  const bound = priorMedian === null ? null : priorMedian * factor;
397
419
  const input = tokens(next.input);
398
420
  const tool = next.action && typeof next.action === "object" ? next.action.tool : null;
@@ -630,7 +652,8 @@ export function evaluateRun({ project, card, repo = REPO, ...opts }) {
630
652
  gates.push({ n: 2, name: "state-mode run recorded", ok: false, code: "NO_RUN", message: `no run steps at ${runPath(project, card)} — the state-mode driver (P6) has not recorded a run for this card, so there is nothing to measure. This is an UNKNOWN, and an unknown is not a pass.` });
631
653
  return { ok: false, gates, halted: "no run", project, card, baseline: base };
632
654
  }
633
- gates.push({ n: 2, name: "state-mode run recorded", ok: true, code: null, message: `${steps.length} steps at ${runPath(project, card)}` });
655
+ const cutRows = steps.length - measurableSteps(steps).length;
656
+ gates.push({ n: 2, name: "state-mode run recorded", ok: true, code: null, message: `${steps.length} steps at ${runPath(project, card)}${cutRows ? ` · ${cutRows} cut row${cutRows === 1 ? "" : "s"} excluded from the measurement gates (3–5); §8.7 still counts ${cutRows === 1 ? "it" : "them"}` : ""}` });
634
657
 
635
658
  const cache = checkCache(steps);
636
659
  gates.push({ n: 3, name: "cache read > 0 after the first step (§8.3)", ok: cache.ok, code: cache.code, message: cache.message });
@@ -16,7 +16,9 @@ import { homedir, hostname } from "node:os";
16
16
  import { execSync, spawn } from "node:child_process";
17
17
  import { fileURLToPath } from "node:url";
18
18
  import { deriveSubagentManifest } from "../../lib/subagent-manifest.mjs";
19
- import { signedPost } from "./api.mjs";
19
+ import { signedPost, loadIdentity } from "./api.mjs";
20
+ // #7037: signedHeaders is SYNCHRONOUS, which is why this path can sign at all — see hubCallSync.
21
+ import { signedHeaders } from "../../lib/signed-fetch.mjs";
20
22
  import { loadAutonomy, resolveAutonomy } from "../../lib/autonomy.mjs";
21
23
  import { resolveProject, orchSessionsPath, hostId } from "../../lib/project.mjs";
22
24
  // Trantor State (TDD §4.5). Dark behind TRANTOR_STATE_HANDOFF: these are imported unconditionally
@@ -473,23 +475,89 @@ export function resolveSeat(projectName, env = process.env) {
473
475
  return env.RELAY_SESSION || (env.RELAY_AGENT ? `${env.RELAY_AGENT}:${projectName}` : `${hostId()}:${projectName}`);
474
476
  }
475
477
 
478
+ // ── #7037: one SIGNED, synchronous hub call, and an error that cannot be mistaken for data ──────
479
+ //
480
+ // Three reads on this path curl'd the hub UNSIGNED and read the refusal as an answer. The hub
481
+ // replies 401 {"error":"signature required"}; a 401 body is VALID JSON, so `JSON.parse(out).tasks
482
+ // || []` parses fine, finds no `tasks` key, and hands back an empty list. The catch never fires
483
+ // because nothing threw. Silently, on every handoff: the card id resolved to 0, the verify-gate
484
+ // list to [], and the storm guard — which exists because an old-hook session once fired 9 handoffs
485
+ // in 49 minutes — read a refusal as "allowed" and stopped guarding.
486
+ //
487
+ // So the shape matters more than the signature: this returns { ok, status, json, reason } and NEVER
488
+ // a bare list. A caller has to look at `ok` before it can reach the data, which is the property the
489
+ // old code lacked. An auth failure must not be spellable as an empty result.
490
+ //
491
+ // Synchronous on purpose — this whole path is (see the state imports above), so api.mjs's async
492
+ // signedGet is unavailable. signedHeaders IS synchronous, so only the transport differs.
493
+ function hubCallSync(path, { project, session, method = "GET", body, timeoutMs = 2500 } = {}) {
494
+ const url = relayUrl(project) + path;
495
+ let headers = {};
496
+ try {
497
+ headers = signedHeaders(loadIdentity(session || resolveSeat(project || "")), url, { method, body });
498
+ } catch { /* unsigned is still worth attempting — the hub decides, not us */ }
499
+ const args = Object.entries(headers).map(([k, v]) => `-H ${JSON.stringify(`${k}: ${v}`)}`);
500
+ if (method !== "GET") args.push("-X", method);
501
+ if (body !== undefined) args.push("-H 'content-type: application/json'", "-d", JSON.stringify(body));
502
+ try {
503
+ // maxBuffer is NOT decoration: /tasks on this project is 1.6MB across 941 cards, and execSync's
504
+ // 1MB default turns that into ENOBUFS — which the old catch would have swallowed straight back
505
+ // into the same silent 0. A fix that only signed the request would still have failed here.
506
+ const out = execSync(
507
+ `curl -s --max-time ${Math.ceil(timeoutMs / 1000)} ${args.join(" ")} -w '\\n%{http_code}' ${JSON.stringify(url)}`,
508
+ { encoding: "utf8", timeout: timeoutMs + 500, maxBuffer: 32 * 1024 * 1024 });
509
+ const cut = out.lastIndexOf("\n");
510
+ const status = Number(out.slice(cut + 1).trim()) || 0;
511
+ const text = cut >= 0 ? out.slice(0, cut) : out;
512
+ let json = null; try { json = text ? JSON.parse(text) : null; } catch {}
513
+ if (status < 200 || status >= 300) {
514
+ return { ok: false, status, json, reason: json?.error ? `HTTP ${status}: ${json.error}` : `HTTP ${status}` };
515
+ }
516
+ return { ok: true, status, json, reason: "" };
517
+ } catch (e) {
518
+ return { ok: false, status: 0, json: null, reason: e?.message || "unreachable" };
519
+ }
520
+ }
521
+
522
+ // A refusal is worth exactly one line on stderr, and it must name the endpoint and the reason — the
523
+ // whole cost of this bug was that it made no sound at all. Never throws into the handoff path: a
524
+ // session losing its baton over a warning is a worse failure than the one being reported.
525
+ function warnHubRead(what, r) {
526
+ try { process.stderr.write(`[trantor] handoff: ${what} unavailable — ${r.reason || "unknown"}; continuing without it\n`); } catch {}
527
+ }
528
+
476
529
  /**
477
530
  * Which card this handoff belongs to. `TRANTOR_CARD` wins — the crew runner knows the answer for
478
531
  * certain and a lookup cannot beat being told. Otherwise ask the hub for this seat's newest open
479
532
  * card, on the same 2s best-effort budget as the verify-gates fetch: a hub that is down costs the
480
533
  * handoff a card number, never the handoff.
534
+ *
535
+ * #7037: reads /catchup, not /tasks. /tasks is the whole board with every card's full log — 1.6MB
536
+ * here, 625-961ms typical, and it already blew a 1500ms budget once today (#6983). /catchup answers
537
+ * the question actually being asked in a few hundred bytes. Its buckets are capped at 8, so "my
538
+ * card is not in the list" and "the list was truncated before it got to me" are different answers,
539
+ * and the second is reported as UNKNOWN rather than quietly resolving to 0 — which is the same
540
+ * defect this card exists to remove, one layer up.
481
541
  */
482
542
  export function resolveHandoffCard({ projectName, seat, env = process.env } = {}) {
483
543
  const told = Number(env.TRANTOR_CARD);
484
544
  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; }
545
+ const r = hubCallSync(`/catchup?project=${encodeURIComponent(projectName)}`, { project: projectName, session: seat });
546
+ if (!r.ok) { warnHubRead(`card lookup for ${projectName}`, r); return 0; }
547
+ const CAP = 8; // hub-side pick() limit; keep in step with /catchup
548
+ for (const status of ["doing", "testing"]) {
549
+ const bucket = Array.isArray(r.json?.[status]) ? r.json[status] : [];
550
+ const mine = bucket
551
+ .filter(t => t && Number.isInteger(t.id) && t.assignee === seat)
552
+ .sort((a, b) => (b.updated || 0) - (a.updated || 0));
553
+ if (mine.length) return mine[0].id;
554
+ if (bucket.length >= CAP) {
555
+ warnHubRead(`card lookup for ${projectName}`,
556
+ { reason: `/catchup ${status} list truncated at ${CAP} — this seat's card may exist but was not returned` });
557
+ return 0;
558
+ }
559
+ }
560
+ return 0;
493
561
  }
494
562
 
495
563
  /**
@@ -625,12 +693,16 @@ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summa
625
693
  // cooldown is SKIPPED — no file, no spawn. Manual (/trantor:handoff) + at-wall (precompact) handoffs
626
694
  // force through. Fail-OPEN if the hub is unreachable, so a legit handoff is never blocked.
627
695
  if (!force) {
628
- try {
629
- const body = JSON.stringify({ project: projectName, session: sessionId || "", trigger: trigger || "auto" });
630
- const out = execSync(`curl -s --max-time 2 -X POST -H 'content-type: application/json' -d ${JSON.stringify(body)} ${JSON.stringify(relayUrl() + "/handoff")}`, { encoding: "utf8", timeout: 2500 });
631
- const r = JSON.parse(out);
632
- if (r && r.allow === false) return { skipped: true, reason: r.reason || "storm-guard", sinceSec: r.sinceSec };
633
- } catch {}
696
+ // #7037: this is the costliest of the three unsigned reads. A 401 body parses, `r.allow` comes
697
+ // back undefined, `undefined === false` is false so the guard said "go" on every handoff and
698
+ // the storm it exists to stop had nothing standing in its way. Signed now, and a REFUSAL is
699
+ // distinguished from a DENIAL: only a hub that answered gets to allow or deny.
700
+ const r = hubCallSync("/handoff", {
701
+ project: projectName, session: sessionId || "", method: "POST",
702
+ body: { project: projectName, session: sessionId || "", trigger: trigger || "auto" },
703
+ });
704
+ if (!r.ok) warnHubRead("storm guard", r); // fail-OPEN, but never silently
705
+ else if (r.json && r.json.allow === false) return { skipped: true, reason: r.json.reason || "storm-guard", sinceSec: r.json.sinceSec };
634
706
  }
635
707
  if (!existsSync(HANDOFF_DIR)) mkdirSync(HANDOFF_DIR, { recursive: true });
636
708
  // #5648: an automatic digest must never recompose+supersede a FRESH model-authored handoff.
@@ -655,11 +727,14 @@ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summa
655
727
  // MUST survive the handoff (a narrative line gets skimmed past; this is what the v0.17.31 incident
656
728
  // taught — the "verify Gail coefficients" intent vanished into prose). Fetched synchronously from
657
729
  // the local hub; best-effort, never blocks the handoff.
730
+ // #7037: signed, and an unreadable list is reported rather than rendered as "no open gates" —
731
+ // a record that silently claims zero gates is worse than one that admits it could not ask.
658
732
  let verifyGates = [];
659
- try {
660
- const out = execSync(`curl -s --max-time 2 ${JSON.stringify(relayUrl() + "/verify-gates?project=" + encodeURIComponent(projectName))}`, { encoding: "utf8", timeout: 2500 });
661
- verifyGates = JSON.parse(out).gates || [];
662
- } catch {}
733
+ {
734
+ const r = hubCallSync(`/verify-gates?project=${encodeURIComponent(projectName)}`, { project: projectName, session: sessionId || "" });
735
+ if (r.ok) verifyGates = Array.isArray(r.json?.gates) ? r.json.gates : [];
736
+ else warnHubRead(`verify gates for ${projectName}`, r);
737
+ }
663
738
  const record = {
664
739
  id: `${projectName}-${stamp}`,
665
740
  project: projectDir, projectName, machine: hostname(),
package/hub/overseer.mjs CHANGED
@@ -36,6 +36,12 @@ function overseerInputs() {
36
36
  llm: v.llm || "", model: v.model || "", status: v.status || "",
37
37
  })),
38
38
  claims: [...fileClaims.values()],
39
+ // #7029: linked-activity needs an EVENT, and a card held from both sides of a link is one of
40
+ // the two the hub already has. Only cards in hand travel — the board is 950+ rows and 99% of
41
+ // them are done, so shipping the open ones keeps this tick as cheap as it was.
42
+ cards: state.tasks
43
+ .filter(t => t.status === "doing" || t.status === "testing")
44
+ .map(t => ({ id: t.id, project: t.project || "", status: t.status, assignee: t.assignee || "", workedBy: t.workedBy || "" })),
39
45
  ...overseerPolicy(),
40
46
  now: now(),
41
47
  };
@@ -160,6 +166,10 @@ function overseerTick() {
160
166
  // intro only to newly arrived sessions, and remember them so they are not re-introduced.
161
167
  standing.lastTick = t;
162
168
  c.since = standing.since;
169
+ // Every standing kind reports DURATION, not a count — the doctrine's rule, and until #7029 only
170
+ // same-project obeyed it. "held for 4h" is the line that tells an operator whether a collision
171
+ // is a moment or a stuck seat; "warned 40 times" tells them only that the watcher is loud.
172
+ if (_sameProject?.durationLabel) c.detail = `${c.detail || ""} (standing for ${_sameProject.durationLabel(t - standing.since)})`.trim();
163
173
  for (const me of parties) if (!standing.sessions.has(me)) intro(c, me, parties);
164
174
  for (const me of parties) standing.sessions.add(me);
165
175
  continue;
package/hub/reaper.mjs CHANGED
@@ -158,6 +158,15 @@ function contractRecipientIsAnswerable(m) {
158
158
  return !!m.to && m.to !== "all" && m.from !== m.to && !m.to.startsWith("hub:") && !m.from.startsWith("hub:");
159
159
  }
160
160
 
161
+ // A direct message the SENDER declared owes nothing back (#7079). `wake:false` is the sender saying
162
+ // "context, not a contract" — the send result even prints "(batched — no turn)" — and a `receipt` or
163
+ // `status` is a report, not a request. None of these ever buys the recipient a turn, so none can be
164
+ // answered; counted as contracts they age into `stalled` and block the dispatcher's stop hook over
165
+ // work that was never owed (four times in one day, every row an ack to an idle seat).
166
+ function contractIsAck(m) {
167
+ return m.wake === false || m.kind === "receipt" || m.kind === "status";
168
+ }
169
+
161
170
  function contractsFor(session, { project = "", windowMs = CONTRACT_WINDOW_MS, overdueMs = null } = {}) {
162
171
  const t = now();
163
172
  const cutoff = t - windowMs;
@@ -174,8 +183,12 @@ function contractsFor(session, { project = "", windowMs = CONTRACT_WINDOW_MS, ov
174
183
 
175
184
  const out = [];
176
185
  for (const c of mine.sort((a, b) => a.ts - b.ts)) {
186
+ const ack = contractIsAck(c);
177
187
  let answer = byRe.get(c.id) || null;
178
- if (!answer) {
188
+ // An ack never claims a LOOSE reply: it is older than the real contract more often than not, and
189
+ // letting it consume the seat's untagged "done" would leave the real row WAITING — a false stall
190
+ // manufactured by the very row that was supposed to owe nothing.
191
+ if (!answer && !ack) {
179
192
  const pool = looseByPeer.get(c.to) || [];
180
193
  const i = pool.findIndex(r => r.ts > c.ts);
181
194
  if (i >= 0) answer = pool.splice(i, 1)[0];
@@ -190,6 +203,7 @@ function contractsFor(session, { project = "", windowMs = CONTRACT_WINDOW_MS, ov
190
203
  // tombstone. A seat that comes back and reports still closes its own contract.
191
204
  let disposition;
192
205
  if (answer) disposition = "answered";
206
+ else if (ack) disposition = "ack"; // nothing owed: never waits, never stalls
193
207
  else if (seen < abandonCut) disposition = "abandoned"; // covers never-seen (seen === 0)
194
208
  else if (!online || (overdueMs != null && ageMs >= overdueMs)) disposition = "stalled";
195
209
  else disposition = "waiting";
@@ -224,7 +238,7 @@ function contractsFor(session, { project = "", windowMs = CONTRACT_WINDOW_MS, ov
224
238
  if (c.answered && c.ts > (newestAnswered.get(c.to) || 0)) newestAnswered.set(c.to, c.ts);
225
239
  }
226
240
  for (const c of out) {
227
- if (c.answered || c.disposition === "abandoned") continue;
241
+ if (c.answered || c.disposition === "abandoned" || c.disposition === "ack") continue;
228
242
  if (c.ageMs < CONTRACT_ABANDON_MS) continue;
229
243
  if (c.ts < (newestAnswered.get(c.to) || 0)) c.disposition = "superseded";
230
244
  }
@@ -248,7 +262,7 @@ function contractsFor(session, { project = "", windowMs = CONTRACT_WINDOW_MS, ov
248
262
  if (r.ts > (latestDirectReply.get(r.from) || 0)) latestDirectReply.set(r.from, r.ts);
249
263
  }
250
264
  for (const c of out) {
251
- if (c.answered || c.disposition === "abandoned") continue;
265
+ if (c.answered || c.disposition === "abandoned" || c.disposition === "ack") continue;
252
266
  if (c.ageMs < CONTRACT_ABANDON_MS) continue;
253
267
  const latest = latestDirectReply.get(c.to);
254
268
  if (latest != null && c.ts < latest) c.disposition = "superseded";
@@ -215,14 +215,15 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
215
215
  declaredCrew: declaredCrewFor(c.project),
216
216
  now: now(),
217
217
  }).reason === "crew-only"));
218
- // The record line reports DURATION ("same-project for 6h"), never a count of warnings.
218
+ // The record line reports DURATION ("standing for 6h"), never a count of warnings. Episode
219
+ // identity is project+kind+files, exactly as the tick loop keys it, so this view says the
220
+ // same thing the warning did  for every kind, not just same-project (#7029).
219
221
  for (const c of warnings) {
220
- if (c.kind !== "same-project-sessions") continue;
221
- const ep = overseer.active.get(`${c.project} same-project-sessions`);
222
- if (ep) {
223
- c.since = ep.since;
224
- if (overseer.sameProject?.durationLabel) c.detail = `${c.detail || ""} (same-project for ${overseer.sameProject.durationLabel(now() - ep.since)})`.trim();
225
- }
222
+ const ep = overseer.active.get(`${c.project} ${c.kind}${c.kind === "same-project-sessions" ? "" : ` ${(c.files || []).join(",")}`}`);
223
+ if (!ep) continue;
224
+ c.since = ep.since;
225
+ const label = c.kind === "same-project-sessions" ? "same-project" : "standing";
226
+ if (overseer.sameProject?.durationLabel) c.detail = `${c.detail || ""} (${label} for ${overseer.sameProject.durationLabel(now() - ep.since)})`.trim();
226
227
  }
227
228
  } catch {}
228
229
  return json(res, 200, { level, links: links.map(l => ({ projects: l.projects, reason: l.reason })), peers: peersOut, inflight, warnings });
@@ -1,4 +1,16 @@
1
1
  /* oxlint-disable anti-slop/no-runtime-typeof, anti-slop/no-conditional-empty-object-spread -- SAFETY: Card wire envelopes and omission semantics are a compatibility contract; this module is a mechanical extraction with unchanged suites. */
2
+ // The three fields that make a card heavy (#6983): its note log, its status history, and its
3
+ // checklist. All three are per-card detail nobody reads off a board — a board shows how many notes
4
+ // a card carries, never their text. Dropping them is what turns a 1.55MB read into 0.26MB.
5
+ const SLIM_DROP = new Set(["log", "history", "checklist"]);
6
+ function slimCard(t) {
7
+ const out = {};
8
+ for (const k of Object.keys(t)) if (!SLIM_DROP.has(k)) out[k] = t[k];
9
+ // Keep the ·N the board renders, so slimming costs a reader nothing visible.
10
+ out.logCount = Array.isArray(t.log) ? t.log.length : 0;
11
+ return out;
12
+ }
13
+
2
14
  export async function routeCards({ req, res, q, P, auth, ctx }) {
3
15
  const {
4
16
  state, body, json, crossProjectGuard, touch, canon, filterReadable,
@@ -361,9 +373,22 @@ export async function routeCards({ req, res, q, P, auth, ctx }) {
361
373
  if (t && !canRead(auth, t.project || "")) return json(res, 404, { id: null, task: null });
362
374
  return json(res, 200, { id: t ? t.id : null, task: t || null });
363
375
  }
376
+ // #6983 fixes 2+3. /tasks is the hot read — every seat pays it several times a session — and on
377
+ // trantor it had grown to 1.55MB across 958 cards: 625-961ms against a 1500ms budget, and past
378
+ // execSync's 1MB pipe. Measured, the weight is not the cards, it is what hangs off them: log
379
+ // 56.3%, history 11.8%, checklist 4.4%. `fields=slim` drops exactly those three and keeps every
380
+ // column a reader actually renders (log becomes logCount) — 16.8% of full. `card=<id>` then
381
+ // re-attaches the ONE card the caller opened, so reading a card costs an index, not the board.
382
+ // Both are opt-in and additive: a client that sends neither gets the unchanged full payload, so
383
+ // this does not move the cliff for anyone, and old clients keep working against a new hub.
364
384
  if (req.method === "GET" && P === "/tasks") {
365
385
  const proj = q.project ? canon(q.project) : ""; const ts = filterReadable(auth, proj ? state.tasks.filter(t => canon(t.project) === proj) : state.tasks, t => t.project || "");
366
- return json(res, 200, { tasks: ts });
386
+ if (String(q.fields || "") !== "slim") return json(res, 200, { tasks: ts });
387
+ const keep = Number(q.card);
388
+ const tasks = ts.map(t => (Number.isInteger(keep) && t.id === keep ? t : slimCard(t)));
389
+ // Echo the projection back. Without it a client cannot tell "hub honored slim" from "old hub
390
+ // ignored the param and sent everything", and would have to guess from a missing field.
391
+ return json(res, 200, { tasks, fields: "slim" });
367
392
  }
368
393
  if (req.method === "GET" && P === "/history") {
369
394
  const requestedLimit = Number(q.limit || 200);
@@ -84,13 +84,16 @@ export async function routeMessages({ req, res, q, P, auth, ctx }) {
84
84
  // `superseded` leaves `contracts` for exactly the reason `abandoned` does: a session's hooks
85
85
  // are PINNED at session start, so an older stop hook filters this array with its own
86
86
  // predicate and would keep blocking on a row the hub has already settled.
87
- const out = all.filter(c => c.disposition !== "abandoned" && c.disposition !== "superseded");
87
+ // `ack` leaves `contracts` too (#7079): a `wake:false` send, a `receipt` or a `status` is the
88
+ // sender declaring nothing is owed, so an old pinned hook must never see it as a row to block on.
89
+ const out = all.filter(c => c.disposition !== "abandoned" && c.disposition !== "superseded" && c.disposition !== "ack");
88
90
  return json(res, 200, {
89
91
  session, contracts: out, abandonedContracts: all.filter(c => c.disposition === "abandoned"),
90
92
  supersededContracts: all.filter(c => c.disposition === "superseded"),
93
+ ackContracts: all.filter(c => c.disposition === "ack"),
91
94
  open: out.filter(c => !c.answered).length,
92
95
  waiting: by("waiting"), stalled: by("stalled"), abandoned: by("abandoned"),
93
- superseded: by("superseded"), answered: by("answered"),
96
+ superseded: by("superseded"), ack: by("ack"), answered: by("answered"),
94
97
  });
95
98
  }
96
99
 
package/lib/overseer.mjs CHANGED
@@ -3,12 +3,10 @@
3
3
  // so the episode-recurrence path was untestable and therefore unproven.
4
4
  const PEER_LIVE_MS = Number(process.env.RELAY_OVERSEER_PEER_LIVE_MS || 5 * 60 * 1000);
5
5
  const CLAIM_LIVE_MS = Number(process.env.RELAY_OVERSEER_CLAIM_LIVE_MS || 10 * 60 * 1000);
6
- // "Actively executing", not merely "window open" — the same 90s bar the desktop app calls `busy`
7
- // (the heartbeat fires on tool calls, so a fresher-than-90s peer is mid-turn). Presence alone is
8
- // too weak a signal to call a collision: see the linked-activity note below.
9
- const WORK_LIVE_MS = Number(process.env.RELAY_OVERSEER_WORK_LIVE_MS || 90 * 1000);
10
-
11
6
  const KINDS = new Set(["same-project-sessions", "file-conflict", "linked-activity"]);
7
+ // A card in `todo` is queued, not held; `done`/`failed`/`blocked` are nobody's hands. Only these
8
+ // two mean a session has it open right now.
9
+ const HELD_STATUSES = new Set(["doing", "testing"]);
12
10
 
13
11
  const asArray = (v) => Array.isArray(v) ? v : [];
14
12
  const clean = (v) => String(v ?? "").trim();
@@ -66,7 +64,7 @@ function sortCollisions(collisions) {
66
64
  );
67
65
  }
68
66
 
69
- export function detectCollisions({ peers = [], claims = [], links = [], autonomy = {}, now } = {}) {
67
+ export function detectCollisions({ peers = [], claims = [], links = [], cards = [], autonomy = {}, now } = {}) {
70
68
  const at = finiteNumber(now) ?? 0;
71
69
  const out = [];
72
70
 
@@ -102,6 +100,11 @@ export function detectCollisions({ peers = [], claims = [], links = [], autonomy
102
100
  }
103
101
 
104
102
  const claimSessionsByFile = new Map();
103
+ // The same claims indexed by PATH ALONE, project dropped. A claim's `project` is the CLAIMANT's
104
+ // project, not the file's, so one path claimed under two project names is two sessions on one
105
+ // file — the cross-project overlap that claimSessionsByFile, keyed on project+file, structurally
106
+ // cannot see. linked-activity reads this one.
107
+ const claimProjectsByPath = new Map();
105
108
  for (const claim of asArray(claims)) {
106
109
  const project = clean(claim?.project);
107
110
  const file = clean(claim?.file);
@@ -111,6 +114,9 @@ export function detectCollisions({ peers = [], claims = [], links = [], autonomy
111
114
  const sessions = claimSessionsByFile.get(key) ?? new Set();
112
115
  sessions.add(session);
113
116
  claimSessionsByFile.set(key, sessions);
117
+ const byProject = claimProjectsByPath.get(file) ?? new Map();
118
+ byProject.set(project, (byProject.get(project) ?? new Set()).add(session));
119
+ claimProjectsByPath.set(file, byProject);
114
120
  }
115
121
 
116
122
  for (const key of [...claimSessionsByFile.keys()].sort()) {
@@ -126,40 +132,75 @@ export function detectCollisions({ peers = [], claims = [], links = [], autonomy
126
132
  });
127
133
  }
128
134
 
129
- // A link is DECLARED — the operator already told us these projects move together. Warning merely
130
- // because both have a session OPEN restates that declaration, and it stays true for hours:
131
- // crebral-health ↔ crebral-scribe produced 468 identical warnings across 8 days (2026-08-12
132
- // audit), which is how a monitor teaches you to ignore it. The signal worth raising is
133
- // CONCURRENT WORK each side actually executing (heartbeat inside WORK_LIVE_MS) or holding a
134
- // fresh file claim. Two idle-open windows are not a collision.
135
- const workingByProject = new Map();
136
- for (const peer of livePeers) {
137
- if (!isFresh(peer?.lastSeen, at, WORK_LIVE_MS)) continue;
138
- const sessions = workingByProject.get(peer.project) ?? [];
139
- sessions.push(peer.session);
140
- workingByProject.set(peer.project, sessions);
141
- }
142
- for (const claim of asArray(claims)) {
143
- const project = clean(claim?.project);
144
- const session = clean(claim?.session);
145
- if (!project || !session || !isFresh(claim?.ts, at, CLAIM_LIVE_MS)) continue;
146
- const sessions = workingByProject.get(project) ?? [];
147
- sessions.push(session);
148
- workingByProject.set(project, sessions);
135
+ // A link is DECLARED — the operator already told us these projects move together, so two sessions
136
+ // being LIVE on both sides restates the operator's own declaration and stays true for as long as
137
+ // the machine is on. crebral-health ↔ crebral-scribe produced 468 identical warnings across 8 days
138
+ // (2026-08-12 audit); trantor trantor-duty is codependent by policy, so on that machine the
139
+ // condition is permanent (#7029: it woke a seat that then spent a full turn proving a negative).
140
+ // Presence is a STATE. Narrowing it to "both sides are executing" was still a state — a busier
141
+ // one. The only thing worth a wake is an EVENT: two sessions actually on the same thing. There
142
+ // are exactly two the hub already holds, so neither has to be invented —
143
+ // (1) one FILE PATH claimed live from both sides of the link (file claims exist for this; the
144
+ // file-conflict kind keys on project+file and so is blind across projects), and
145
+ // (2) one CARD ID held (doing/testing) by live sessions from both sides — `assignee` is intent
146
+ // and `workedBy` is the signed evidence of who actually moved it, so a card wearing two
147
+ // live faces from two linked projects is two sessions on one piece of work.
148
+ // No event, no warning. This under-warns by construction and that is the intended bias: a warning
149
+ // that fires on a permanent condition teaches its readers to ignore it.
150
+ const projectOfSession = new Map();
151
+ for (const peer of livePeers) if (!projectOfSession.has(peer.session)) projectOfSession.set(peer.session, peer.project);
152
+
153
+ const heldCards = [];
154
+ for (const card of asArray(cards)) {
155
+ const id = finiteNumber(card?.id);
156
+ if (id == null || !HELD_STATUSES.has(clean(card?.status))) continue;
157
+ // Only LIVE sessions count. A card assigned to a seat that went home is not a collision.
158
+ const holders = sortedStrings([card?.assignee, card?.workedBy]).filter((s) => projectOfSession.has(s));
159
+ if (holders.length < 2) continue;
160
+ heldCards.push({ id, holders });
149
161
  }
150
162
 
151
163
  for (const link of asArray(links)) {
152
164
  const projects = sortedStrings(link?.projects ?? []);
153
165
  if (projects.length < 2) continue;
154
- const activeProjects = projects.filter((project) => (workingByProject.get(project) ?? []).length > 0);
155
- if (activeProjects.length < 2) continue;
156
- const sessions = sortedStrings(activeProjects.flatMap((project) => workingByProject.get(project) ?? []));
166
+ const inLink = new Set(projects);
167
+ const files = [];
168
+ const sessions = new Set();
169
+ const contested = new Set();
170
+ const evidence = [];
171
+
172
+ for (const path of [...claimProjectsByPath.keys()].sort()) {
173
+ const claimSides = [...claimProjectsByPath.get(path).entries()].filter(([project]) => inLink.has(project));
174
+ if (claimSides.length < 2) continue;
175
+ const claimants = sortedStrings(claimSides.flatMap(([, ss]) => [...ss]));
176
+ if (claimants.length < 2) continue;
177
+ files.push(path);
178
+ for (const [project] of claimSides) contested.add(project);
179
+ for (const s of claimants) sessions.add(s);
180
+ evidence.push(`${path} is claimed by ${claimants.join(", ")}`);
181
+ }
182
+
183
+ for (const card of heldCards) {
184
+ const holders = card.holders.filter((s) => inLink.has(projectOfSession.get(s)));
185
+ const holderProjects = new Set(holders.map((s) => projectOfSession.get(s)));
186
+ if (holderProjects.size < 2) continue;
187
+ for (const project of holderProjects) contested.add(project);
188
+ for (const s of holders) sessions.add(s);
189
+ evidence.push(`card #${card.id} is held by ${holders.join(", ")}`);
190
+ }
191
+
192
+ if (evidence.length === 0) continue;
193
+ // ONE collision per link, evidence and all. The episode key downstream is project+kind+files, so
194
+ // per-file splitting would mint a fresh episode (and a fresh wake) every time the evidence set
195
+ // shifted — the same volatility that made membership a bad episode key (#5350). A second
196
+ // contended card inside a standing episode therefore stays silent; under-warning is the bias.
197
+ const sides = sortedStrings(contested);
157
198
  pushCollision(out, {
158
- project: activeProjects[0],
199
+ project: sides[0] ?? projects[0],
159
200
  kind: "linked-activity",
160
- sessions,
161
- files: [],
162
- detail: `Linked projects ${activeProjects.join(", ")} are being worked on at the same time by ${sessions.join(", ")}.`,
201
+ sessions: [...sessions],
202
+ files,
203
+ detail: `Linked projects ${sides.join(", ")} are on the same work: ${evidence.join("; ")}.`,
163
204
  });
164
205
  }
165
206
 
@@ -14,6 +14,22 @@
14
14
  /// The first card a message cites. A turn belongs to exactly one card, and this is how the runner
15
15
  /// knows when a wake has moved to a different one.
16
16
  export const CARD_REF_RE = /#(\d{1,7})(?!\d)/;
17
+ const CARD_REF_RE_G = /#(\d{1,7})(?!\d)/g;
18
+
19
+ /// #7061: a citation that HANDS THE CARD OVER, as opposed to one that merely mentions it. A work
20
+ /// order naturally opens with what shipped since the seat's last turn — "#7037 is merged as
21
+ /// a01f629 … YOUR CARD: #6983" — so the earliest id in the prose is routinely a DONE card. Binding
22
+ /// to it wrote the state sidecar, the card log and the run record against a card nobody worked.
23
+ /// Shape is what separates the two roles; position never did. Deliberately narrow: an assignment
24
+ /// verb or label immediately before the id, never a general parse of English.
25
+ const ASSIGN_CARD_RE = new RegExp([
26
+ // a label: "YOUR CARD: #6983", "card #7010", "contract: #7061", "work order — #6897"
27
+ String.raw`\b(?:your\s+card|card|contract|work\s+order|assignment)\s*(?:is\s*)?[:\u2014\u2013-]?\s*#(\d{1,7})(?!\d)`,
28
+ // a verb that hands it over: "take #7061", "take card #6897", "work on #7001", "bounce on #6134"
29
+ String.raw`\b(?:take|work|pick\s+up|resume|start|finish|bounced?)\s+(?:on\s+)?(?:card\s+)?#(\d{1,7})(?!\d)`,
30
+ // the card as the subject of a hand-over: "#7061 is yours", "#7002 is bounced"
31
+ String.raw`#(\d{1,7})(?!\d)\s+(?:is|are)\s+(?:yours|bounced|reopened|back)\b`,
32
+ ].join("|"), "i");
17
33
 
18
34
  /// Words that make a direct message an instruction rather than conversation. Deliberately short:
19
35
  /// the point is to catch a contract that forgot to cite its card, not to parse English.
@@ -24,6 +40,84 @@ export function cardRef(text) {
24
40
  return m ? Number(m[1]) : 0;
25
41
  }
26
42
 
43
+ /// Every card a message cites, in the order it cites them — what the runner uses to tell a wake
44
+ /// that names ONE card from one that names several.
45
+ export function cardRefs(text) {
46
+ return [...String(text || "").matchAll(CARD_REF_RE_G)].map((m) => Number(m[1]));
47
+ }
48
+
49
+ /// The card this text ASSIGNS, or 0 when it only mentions cards. Leftmost assignment wins: an
50
+ /// order says what it wants first and explains itself after.
51
+ export function assignedCardRef(text) {
52
+ const m = ASSIGN_CARD_RE.exec(String(text || ""));
53
+ return m ? Number(m.slice(1).find((g) => g != null)) : 0;
54
+ }
55
+
56
+ /// #7061: the card a wake batch binds its turn to. Messages addressed to THIS seat outrank
57
+ /// @mentions (a mention is someone else's contract that named you), and within them the NEWEST
58
+ /// assignment wins, because a later order supersedes an earlier one. Only when no message in the
59
+ /// batch is assignment-shaped does this fall back to the newest message's first citation — the
60
+ /// pre-#7061 behaviour, narrowed from "first id anywhere in the batch" to one message.
61
+ export function wakeCard(messages, { session = "" } = {}) {
62
+ const all = (Array.isArray(messages) ? messages : []).filter(Boolean);
63
+ if (!all.length) return 0;
64
+ const direct = session ? all.filter((m) => m.to === session) : [];
65
+ const pool = newestLast(direct.length ? direct : all);
66
+ for (let i = pool.length - 1; i >= 0; i--) {
67
+ const c = assignedCardRef(pool[i].text);
68
+ if (c) return c;
69
+ }
70
+ for (let i = pool.length - 1; i >= 0; i--) {
71
+ const c = cardRef(pool[i].text);
72
+ if (c) return c;
73
+ }
74
+ // A batch whose only citation sits in an @mention still names a card; nothing is worse than 0.
75
+ return all.map((m) => cardRef(m.text)).find(Boolean) || 0;
76
+ }
77
+
78
+ /// Chronological order, by whichever field EVERY message in the batch actually has. Mixing hub ids
79
+ /// with wall-clock ts across messages would sort by two different scales and silently misorder, so
80
+ /// a batch that is not uniform keeps the order the runner queued it in.
81
+ function newestLast(msgs) {
82
+ for (const key of ["id", "ts"]) {
83
+ if (msgs.every((m) => Number.isFinite(Number(m?.[key])))) {
84
+ return msgs.map((m, i) => ({ m, i }))
85
+ .sort((a, b) => (Number(a.m[key]) - Number(b.m[key])) || (a.i - b.i))
86
+ .map((x) => x.m);
87
+ }
88
+ }
89
+ return msgs;
90
+ }
91
+
92
+ /// Turn kinds that exist BEFORE or WITHOUT a wake message. Neither can carry a card, so neither
93
+ /// can ever be a state step. That is a fact about the SHAPE of the turn, not about configuration,
94
+ /// which is exactly why no amount of correct config makes it come out differently.
95
+ const NO_CARD_TURNS = {
96
+ kickoff: "a kickoff runs before any message arrives, so it belongs to no card",
97
+ pulse: "a pulse is a timer rather than a message, so it belongs to no card",
98
+ };
99
+
100
+ /// #7060: why THIS turn is not assembled from a WorkingState, or null when it is. The runner's
101
+ /// boot line only ever proved CONFIGURATION — the flag, an eligible seat, a CLI carrying
102
+ /// `--json-schema`, the schema on disk — and an operator read it as a claim about the PROMPT. The
103
+ /// two came apart on the very next turn: the kickoff runs before any message exists, so it belongs
104
+ /// to no card and can never be a state step, and it said nothing at all about that. The seat that
105
+ /// hit it had confirmed flags, a valid schema and an unassembled prompt, and the only way to tell
106
+ /// a skip from proof was to read the source. A skip must not be mistaken for proof — the same rule
107
+ /// the drill's SKIP already keeps (bin/drill-surface.mjs:39) — so the runner asks this every turn
108
+ /// and prints the answer.
109
+ ///
110
+ /// Reasons are ordered by which constraint actually BINDS. A pulse belongs to no card whether or
111
+ /// not the breaker tripped, so on those turns the structural fact is the honest answer.
112
+ export function stateSkipReason({ mode = false, kind = "wake", breakerTripped = false, card = 0 } = {}) {
113
+ if (!mode) return "state mode is off for this seat";
114
+ const structural = NO_CARD_TURNS[String(kind)];
115
+ if (structural) return structural;
116
+ if (breakerTripped) return "the state-mode breaker tripped earlier this run, so the seat is back on the transcript path";
117
+ if (!(Number(card) > 0)) return "this wake assigns no card, and a state turn is bound to exactly one";
118
+ return null;
119
+ }
120
+
27
121
  export function hasImperative(text) {
28
122
  return IMPERATIVE_RE.test(String(text || ""));
29
123
  }
package/mcp.mjs CHANGED
@@ -154,7 +154,7 @@ server.tool("relay_contracts", "What you dispatched and are still owed. Lists ev
154
154
  catch (e) { return { content: [{ type: "text", text: `could not reach the hub: ${e?.message || e}` }] }; }
155
155
  // The hub keeps abandoned contracts in their own key so older stop hooks stop blocking on them.
156
156
  // The ledger still wants to SHOW them, so put the two halves back together here.
157
- const all = [...(r?.contracts || []), ...(r?.abandonedContracts || []), ...(r?.supersededContracts || [])]
157
+ const all = [...(r?.contracts || []), ...(r?.abandonedContracts || []), ...(r?.supersededContracts || []), ...(r?.ackContracts || [])]
158
158
  .sort((a, b) => a.ts - b.ts);
159
159
  if (!all.length) return { content: [{ type: "text", text: "You have not dispatched any contracts in the last 24h." }] };
160
160
  // Fall back to the pre-disposition shape when talking to an older hub.
@@ -162,8 +162,9 @@ server.tool("relay_contracts", "What you dispatched and are still owed. Lists ev
162
162
  const open = all.filter(c => disp(c) === "waiting" || disp(c) === "stalled");
163
163
  const abandoned = all.filter(c => disp(c) === "abandoned");
164
164
  const superseded = all.filter(c => disp(c) === "superseded");
165
+ const acks = all.filter(c => disp(c) === "ack");
165
166
  const mins = (ms) => (ms >= 60000 ? `${Math.round(ms / 60000)}m` : `${Math.round(ms / 1000)}s`);
166
- const MARK = { answered: "✅", waiting: "⏳", stalled: "⚠️", abandoned: "🪦", superseded: "⤳" };
167
+ const MARK = { answered: "✅", waiting: "⏳", stalled: "⚠️", abandoned: "🪦", superseded: "⤳", ack: "·" };
167
168
  const line = (c) => {
168
169
  const d = disp(c);
169
170
  const health = d === "answered" ? "" :
@@ -184,9 +185,13 @@ server.tool("relay_contracts", "What you dispatched and are still owed. Lists ev
184
185
  if (superseded.length) {
185
186
  notes.push(`⤳ ${superseded.length} SUPERSEDED: the assignee is alive and has since answered a newer contract from you, so these were never going to be answered. Nothing is owed — do not chase them.`);
186
187
  }
187
- const text = `${open.length} outstanding of ${all.length} contract(s) in the last 24h`
188
+ if (acks.length) {
189
+ notes.push(`· ${acks.length} ACK: sent with wake:false, or as a receipt/status. You declared nothing was owed on these, so they never wait and never stall.`);
190
+ }
191
+ const text = `${open.length} outstanding of ${all.length - acks.length} contract(s) in the last 24h`
188
192
  + (abandoned.length ? ` (plus ${abandoned.length} abandoned)` : "")
189
- + (superseded.length ? ` (plus ${superseded.length} superseded)` : "") + `:\n`
193
+ + (superseded.length ? ` (plus ${superseded.length} superseded)` : "")
194
+ + (acks.length ? ` (plus ${acks.length} acks, nothing owed)` : "") + `:\n`
190
195
  + all.slice(-25).map(line).join("\n")
191
196
  + (notes.length ? "\n\n" + notes.join("\n") : "");
192
197
  return { content: [{ type: "text", text }] };
@@ -332,6 +337,10 @@ const STOPWORDS = new Set(["the","a","an","and","or","of","to","in","on","for","
332
337
  function titleWords(title) {
333
338
  return new Set(String(title || "").toLowerCase().match(/[a-z][a-z0-9_-]{2,}/g)?.filter(w => !STOPWORDS.has(w)) || []);
334
339
  }
340
+ // How many notes a card carries, whichever shape the hub sent (#6983). A slim card has been stripped
341
+ // of its log and carries logCount instead; a full card — an older hub, or the one card we asked for
342
+ // in full — still has the log itself. Read both so the ·N is right against any hub version.
343
+ const noteCount = (t) => (Array.isArray(t.log) ? t.log.length : Number(t.logCount) || 0);
335
344
  function cardView(tasks, id, proj) {
336
345
  const card = tasks.find(t => t.id === id);
337
346
  if (!card) return `${proj}: no card #${id}`;
@@ -358,7 +367,7 @@ function cardView(tasks, id, proj) {
358
367
  .filter(t => t.status === "done" && t.id !== card.id && [...titleWords(t.title)].some(w => mine.has(w)))
359
368
  .sort((a, b) => (b.updated || b.ts || 0) - (a.updated || a.ts || 0))
360
369
  .slice(0, 5)
361
- .map(t => `#${t.id} ${t.title}${t.log?.length ? ` ·${t.log.length}` : ""}`);
370
+ .map(t => `#${t.id} ${t.title}${noteCount(t) ? ` ·${noteCount(t)}` : ""}`);
362
371
  if (kin.length) out.push(`related done cards:\n ${kin.join("\n ")}`);
363
372
 
364
373
  return out.join("\n");
@@ -369,11 +378,16 @@ server.tool("relay_board", "Show a project's Kanban board (all cards + their sta
369
378
  card: z.number().optional().describe("read ONE card instead of the board: the card itself, its notes, and the last five done cards whose title shares a word with it. This is what a seat starting a card should call — the whole board is 1900+ cards of someone else's work.") },
370
379
  async ({ project, card }) => {
371
380
  const proj = project || PROJECT;
372
- const { tasks } = await api("GET", `/tasks?project=${encodeURIComponent(proj)}`);
381
+ // #6983: ask for the SLIM projection, and when opening one card ask for that card full. The board
382
+ // needs titles and a note COUNT, never every card's note text — on trantor that was 1.55MB and
383
+ // 625-961ms against a 1500ms budget, which is why this tool intermittently answered "hub 0" when
384
+ // the hub was 200 OK and merely slow. An older hub ignores both params and returns the full board,
385
+ // which still renders — so this asks for cheap and works either way, rather than requiring a deploy.
386
+ const { tasks } = await api("GET", `/tasks?project=${encodeURIComponent(proj)}&fields=slim${card ? `&card=${encodeURIComponent(card)}` : ""}`);
373
387
  if (!tasks.length) return { content: [{ type: "text", text: `${proj}: no cards yet` }] };
374
388
  if (card) return { content: [{ type: "text", text: cardView(tasks, card, proj) }] };
375
389
  const by = { todo: [], doing: [], testing: [], failed: [], done: [], blocked: [] };
376
- for (const t of tasks) (by[t.status] || by.todo).push(`#${t.id} ${t.title}${t.assignee ? ` (@${t.assignee})` : ""}${t.log?.length ? ` ·${t.log.length}` : ""}`);
390
+ for (const t of tasks) (by[t.status] || by.todo).push(`#${t.id} ${t.title}${t.assignee ? ` (@${t.assignee})` : ""}${noteCount(t) ? ` ·${noteCount(t)}` : ""}`);
377
391
  const cols = Object.entries(by).filter(([, v]) => v.length).map(([k, v]) => `${k.toUpperCase()}:\n ${v.join("\n ")}`);
378
392
  return { content: [{ type: "text", text: `${proj} board\n${cols.join("\n")}` }] };
379
393
  });
@@ -391,8 +405,9 @@ server.tool("relay_peers", "Find who you can talk to: the live agent sessions on
391
405
 
392
406
  server.tool("relay_send", "Send a live message to another agent session (or 'all' to broadcast). Reach the other agent YOURSELF: if you are about to ask the human to pass something along, tell the session directly instead — asking a person to carry a message between two agents is a failure, not politeness. Don't know the id? relay_peers lists them, linked projects included. Cross-project action is a breach unless the operator linked the projects (`trantor policy link <a> <b> --reason \"<why>\"`) — the hub answers 403 for a send into an unlinked project.",
393
407
  { to: z.string().describe("target session id, or 'all'"), text: z.string().describe("message body"),
394
- wake: z.boolean().optional().describe("false = context, not a contract: the message batches into the target's next turn instead of buying it a whole CLI session. Use it for acks, FYIs and queue notes; leave it unset for anything you expect worked on.") },
395
- async ({ to, text, wake }) => {
408
+ wake: z.boolean().optional().describe("false = context, not a contract: the message batches into the target's next turn instead of buying it a whole CLI session. Use it for acks, FYIs and queue notes; leave it unset for anything you expect worked on."),
409
+ re: z.number().optional().describe("the message id you are ANSWERING. Set it whenever you reply to a specific message. Without it the hub matches your reply to the peer's OLDEST outstanding contract, so answering their newest question silently closes their oldest one and leaves the real one reading WAITING forever — which is how seats end up chasing contracts that were answered long ago.") },
410
+ async ({ to, text, wake, re }) => {
396
411
  // The event log is append-only — a secret in it is unrecoverable, so refuse BEFORE
397
412
  // anything reaches the hub. Returns the offending kinds so the caller can fix it.
398
413
  const scrub = assertNoSecrets(text);
@@ -401,7 +416,7 @@ server.tool("relay_send", "Send a live message to another agent session (or 'all
401
416
  }
402
417
  let sent;
403
418
  try {
404
- sent = await api("POST", "/send", { from: SESSION, to, text, ...(wake === false ? { wake: false } : {}) });
419
+ sent = await api("POST", "/send", { from: SESSION, to, text, ...(wake === false ? { wake: false } : {}), ...(Number.isFinite(re) ? { re: Number(re) } : {}) });
405
420
  } catch (error) {
406
421
  // A duty relay refusal is itself a fleet incident. Record it on the target lane before the
407
422
  // tool returns the 403; the model must not interpret a failed report as permission to skip
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.52",
3
+ "version": "0.18.53",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
@@ -122,6 +122,20 @@ Loop until the board is done — you are a foreman, not a mailbox:
122
122
  - **ABANDONED** — the assignee has been gone long enough that the contract can never be
123
123
  answered. Nobody is coming back. **Reassign the work or drop it deliberately** — it will not
124
124
  block you again, and it will not resolve itself.
125
+ - **SUPERSEDED** — the assignee is alive and has since answered a NEWER contract from you, so
126
+ this row was never going to be answered on its own. Nothing is owed. Do not chase it.
127
+ - **ACK** — you sent it with `wake:false`, or as a `receipt`/`status`. You declared that nothing
128
+ was owed, so it never waits and never stalls. It stays in the ledger as a record that you said
129
+ the thing; it is not work you are waiting on.
130
+
131
+ Two sizing rules that cost real money to learn:
132
+ - **A turn is killed at 20 minutes** (`TURN_MAX_MS`), so write a contract that can FINISH inside
133
+ one. A seat cut at the box loses the turn, and on the state path nothing salvages it. If a
134
+ seat reports a "crash", check `duration_ms` against 1200000 before believing it — SIGKILL at
135
+ the box exits 137 and reads like a fault.
136
+ - **A wake is capped at 2000 characters and truncated HEAD-first**, which eats your instructions
137
+ and keeps your rationale. Put the ask in the FIRST sentence, keep it short, and split a long
138
+ order into two sends rather than one long one.
125
139
  A contract is never closed on the assignee's behalf: quiet is not an outcome. An abandoned one
126
140
  stays in the ledger with the evidence, and a seat that revives still closes its own contract.
127
141
  4. Grunt sub-tasks that appear mid-build (a regex, a config block, a doc paragraph) →