trantor 0.18.57 → 0.18.58

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.57",
3
+ "version": "0.18.58",
4
4
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
5
5
  "mcpServers": {
6
6
  "relay": {
package/bin/cli.mjs CHANGED
@@ -33,6 +33,8 @@ switch (cmd) {
33
33
  case "agent-settings": run("bin/agent-settings.mjs"); break;
34
34
  case "adopt": spawn(process.execPath, [join(ROOT, "bin/adopt.mjs"), ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
35
35
  case "integrate": spawn(process.execPath, [join(ROOT, "bin/integrate.mjs"), ...args], { stdio: "inherit", cwd: process.cwd() }).on("exit", c => process.exit(c ?? 0)); break;
36
+ case "harvest": run("bin/harvest.mjs"); break;
37
+ case "sync": run("bin/sync.mjs"); break;
36
38
  case "down": runCrew(); break;
37
39
  case "swap": runCrew(); break;
38
40
  case "prune": runCrew(); break;
@@ -207,6 +209,8 @@ switch (cmd) {
207
209
  trantor adopt take over a session already running in a Terminal, then open it here
208
210
  trantor takeover the whole move in one command: idle-gate the Terminal session, end it gracefully, adopt, open in the pane — [--force] [--session <id>] [--dry-run]
209
211
  trantor integrate collect the crew's work, merge it, verify it, push it (--dry-run to rehearse)
212
+ trantor harvest receipt for a seat commit you landed on main by hand: harvest <seat-sha> <main-sha> [--card N]
213
+ trantor sync realign a seat branch with main from the receipts: sync [<seat>] [--dry-run] — refuses and names unharvested commits
210
214
  trantor down tear the crew down (kills processes, closes windows, no dialogs)
211
215
  trantor prune drop dead crew-window tracking rows (ghost workspaces/panes) without spawning anything
212
216
  trantor ui open the live dashboard (board + flow views)
@@ -12,7 +12,7 @@ import { ensureEnrolled } from "../lib/enroll.mjs";
12
12
  import { redactKeys } from "../lib/redact.mjs";
13
13
  import {
14
14
  AUTH_MARKER_RE, classifyFailure, looksLikeAuthDeath,
15
- verdictFor,
15
+ verdictFor, substantiveOutput,
16
16
  readPromptText, stripPromptEcho,
17
17
  } from "../lib/classify-failure.mjs";
18
18
  import { capWake, capBcast, pickLessons, composePrompt } from "./crew-payload.mjs";
@@ -284,7 +284,7 @@ if (!CLI[AGENT]) log(`'${AGENT}' is not a built-in seat — running it as an ope
284
284
 
285
285
  // RUNNER_RULES / RUNNER_KICKOFF env overrides: the runner is also the substrate for non-crew
286
286
  // always-on seats (the fleet DUTY agent, bin/duty.mjs) whose doctrine is not "work your card".
287
- const RULES = process.env.RUNNER_RULES || `Rules: you are ${SESSION} on the trantor crew. Before starting a card, read YOUR card: relay_board with card:<id> (the card, its deps, its notes, and the last five done cards whose title shares a word); never the whole board. Work your assigned file(s), report on the bus (relay_send, <280 chars), move your Kanban card as you go with a NOTE saying what you did (doing -> testing -> done; in 'testing' run YOUR OWN test file — never the full npm test, suites collide across seats — plus \`node bin/slop-gate.mjs\` when the repo has one: it lints ONLY your changed files against the anti-slop rules, and a card must not reach done with slop-gate failing; use 'failed' + a report if anything breaks). If you need something from another session, message THAT SESSION (relay_peers to find its id, relay_send to reach it) — never ask the human to pass it along; carrying messages between agents is the job this bus exists to remove. When your work for THIS message is finished, END YOUR TURN — do NOT park, do NOT loop relay_wait; the runner waits for you and will wake you with the next message. Path discipline: build/test from your worktree root ${TURN_DIR} with absolute paths or --manifest-path/--prefix instead of cd-ing into subdirs, and put anything that must land outside the repo under ${TURN_DIR}/.agent-bus-out/ (gitignored) — never ~/.agent-bus. Cross-project action is a breach: never \`trantor up\` a crew, register a seat, or send a card/contract into a project other than ${PROJ} unless the operator ran \`trantor policy link ${PROJ} <other> --reason "<why>"\` first — the hub, the CLI and this runner all refuse it mechanically, so ask the operator to link the projects instead of routing around the refusal.`;
287
+ const RULES = process.env.RUNNER_RULES || `Rules: you are ${SESSION} on the trantor crew. Before starting a card, read YOUR card: relay_board with card:<id> (the card, its deps, its notes, and the last five done cards whose title shares a word); never the whole board. Work your assigned file(s), report on the bus (relay_send, <280 chars), move your Kanban card as you go with a NOTE saying what you did (doing -> testing -> done; in 'testing' run YOUR OWN test file — never the full npm test, suites collide across seats — plus \`node bin/slop-gate.mjs\` when the repo has one: it lints ONLY your changed files against the anti-slop rules, and a card must not reach done with slop-gate failing; use 'failed' + a report if anything breaks). If you need something from another session, message THAT SESSION (relay_peers to find its id, relay_send to reach it) — never ask the human to pass it along; carrying messages between agents is the job this bus exists to remove. When your work for THIS message is finished, END YOUR TURN — do NOT park, do NOT loop relay_wait; the runner waits for you and will wake you with the next message. Path discipline: build/test from your worktree root ${TURN_DIR} with absolute paths or --manifest-path/--prefix instead of cd-ing into subdirs, and put anything that must land outside the repo under ${TURN_DIR}/.agent-bus-out/ (gitignored) — never ~/.agent-bus. Realigning your seat branch after the orchestrator harvested your commits is \`trantor sync\` run from your worktree: it reads the harvest receipts and refuses when an unharvested commit would be lost, so never reset or rebase onto main by hand. Cross-project action is a breach: never \`trantor up\` a crew, register a seat, or send a card/contract into a project other than ${PROJ} unless the operator ran \`trantor policy link ${PROJ} <other> --reason "<why>"\` first — the hub, the CLI and this runner all refuse it mechanically, so ask the operator to link the projects instead of routing around the refusal.`;
288
288
 
289
289
  // ---- the pulse --------------------------------------------------------------
290
290
  // RUNNER_PULSE_MS re-runs an orchestrator seat's mission note on a cadence when the bus is silent;
@@ -305,6 +305,10 @@ let lastErrText = "";
305
305
  // burned its whole max_tokens budget on internal reasoning and returned a null completion; the
306
306
  // runner used to read that silence as a clean turn while nothing was produced.
307
307
  let lastEmptyOutput = false;
308
+ // #7759: exit 0 with bytes on the stream but neither a changed worktree nor substantive output —
309
+ // the CLI printed its banner and quit. The both-streams-silent rule above stays; this is the
310
+ // second, harder-to-see empty shape.
311
+ let lastEmptyTurn = false;
308
312
  const ERRF = join(homedir(), ".agent-bus", `err-${AGENT}-${PROJ}.txt`);
309
313
  const DUTY_NUDGES = process.env.RUNNER_DUTY_NUDGES === "1";
310
314
  const DUTY_NUDGE_STATE = process.env.RUNNER_DUTY_NUDGE_STATE
@@ -357,7 +361,7 @@ function loadPending() {
357
361
  // Auth failures in TURN OUTPUT: opencode prints its auth error and still exits 0 (#5405). The rules
358
362
  // live in lib/classify-failure.mjs (#5868); runTurn judges only the CLI's own output, not the echo.
359
363
  function classify(exit) {
360
- const { reason, matched } = classifyFailure(exit, lastErrText, lastEmptyOutput);
364
+ const { reason, matched } = classifyFailure(exit, lastErrText, lastEmptyOutput, lastEmptyTurn);
361
365
  log(`classified ${reason} because ${matched}`);
362
366
  return reason;
363
367
  }
@@ -507,6 +511,20 @@ async function notifyAssigners(pairs, text) {
507
511
  if (seen.size) log(`reported outcome to ${[...seen].join(", ")}`);
508
512
  }
509
513
 
514
+ // #7759: bus activity is work. The hub's unified log already records sends and card moves/notes
515
+ // with the actor (`by`), and GET /events filters by it — so the runner can see, with no new
516
+ // endpoint, that the seat answered on the bus even when it printed nothing and touched no file.
517
+ // Fail-open: an unreachable or older hub yields "no activity", the pre-fix behaviour.
518
+ async function latestBusEventId() {
519
+ try { const r = await api("/events?limit=1"); return Number(r?.latest) || 0; } catch { return 0; }
520
+ }
521
+ async function busActivitySince(seq) {
522
+ try {
523
+ const r = await api(`/events?by=${encodeURIComponent(SESSION)}&since=${seq}&limit=500`);
524
+ return Array.isArray(r?.events) && r.events.some(e => Number(e?.id) > seq);
525
+ } catch { return false; }
526
+ }
527
+
510
528
  async function reportHealthy() {
511
529
  if (consecFails === 0) return; // already healthy — don't spam
512
530
  consecFails = 0;
@@ -632,6 +650,12 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
632
650
  // exit-0 turn with real output must never be re-labelled "auth" by the #5405 escalation — the
633
651
  // qwen specimen committed aa3c340 while its captured stream still tripped the auth regex.
634
652
  const headBefore = gitOut(["rev-parse", "HEAD"], TURN_DIR);
653
+ // #7759: the worktree snapshot the turn is judged against at its end — porcelain covers edits
654
+ // AND new untracked files, which a HEAD-only comparison misses.
655
+ const statusBefore = gitOut(["status", "--porcelain"], TURN_DIR);
656
+ // #7759: the bus event cursor the turn is judged against — anything the seat posts after this
657
+ // (relay_send, a card move or note) is bus activity, which counts as work.
658
+ const busSeqBefore = await latestBusEventId();
635
659
  // #6154: a pinned seat with no sid yet resumes as FRESH — the guard below fails open, because
636
660
  // a resume without an id must fall back to a new session, never to `next`'s bare resume shape.
637
661
  let cmd = (isFirst || ((cli.sid || cli.pinned) && !sid)) ? cli.first : cli.next;
@@ -786,9 +810,18 @@ exit $turn_exit`;
786
810
  lastEmptyOutput = true;
787
811
  log("\x1b[31mexit 0 but the turn produced NO output — treating as FAILED (empty-output)\x1b[0m");
788
812
  }
813
+ // #7759: the turn is HOLLOW when it exited 0 having neither changed the worktree (HEAD or
814
+ // porcelain vs the turn-start snapshot, untracked included), said anything beyond CLI chrome,
815
+ // nor posted to the bus. Computed LAST and only when the escalations above did not claim the
816
+ // turn: an auth death or a null completion is never re-labelled empty (#7759, #5405, #5481).
817
+ const worktreeChanged = newCommit || gitOut(["status", "--porcelain"], TURN_DIR) !== statusBefore;
818
+ const busActive = await busActivitySince(busSeqBefore);
819
+ lastEmptyTurn = !cut && realExit === 0 && effExit === 0 && !authHit && !opts.state
820
+ && !worktreeChanged && !busActive && !substantiveOutput(ownOut);
821
+ if (lastEmptyTurn) log("\x1b[33mexit 0 but the turn was EMPTY — no worktree change, no substantive output, no bus activity\x1b[0m");
789
822
  // #5868: the verdict rides the telemetry row so a classification survives the pane scrolling
790
823
  // away — the same "classified X because Y" shape the runner logs, in the seat's jsonl forever.
791
- const verdict = verdictFor(realExit, effExit, lastEmptyOutput, ownOut);
824
+ const verdict = verdictFor(realExit, effExit, lastEmptyOutput, ownOut, lastEmptyTurn);
792
825
  // #6134: what the turn COST, from the CLI's own usage line. Zero means this CLI printed none —
793
826
  // never that the turn was free. `trantor seat-why` totals these into today's spend per seat.
794
827
  let tokens = parseTurnTokens(ownOut);
@@ -801,8 +834,8 @@ exit $turn_exit`;
801
834
  // #6289: every ledger row names in ONE field what happened to the turn — cut (the box ended it),
802
835
  // api-error (the CLI failed), completed — and what it cost in tokens, even when this CLI printed
803
836
  // no usage line (0 means "not reported", never "free"). `cut` stays too: the drills read it.
804
- const outcome = cut ? "cut" : (effExit !== 0 ? "api-error" : "completed");
805
- const telemetryRow = { ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, model: MODEL || "cli-default", duration_ms: Date.now() - t0, exit: realExit, effExit, authFailed: effExit !== realExit, emptyOutput: lastEmptyOutput, verdict, outcome, tokens };
837
+ const outcome = cut ? "cut" : (effExit !== 0 ? "api-error" : lastEmptyTurn ? "empty" : "completed");
838
+ const telemetryRow = { ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, model: MODEL || "cli-default", duration_ms: Date.now() - t0, exit: realExit, effExit, authFailed: effExit !== realExit, emptyOutput: lastEmptyOutput, emptyTurn: lastEmptyTurn, verdict, outcome, tokens };
806
839
  if (cut) telemetryRow.cut = true;
807
840
  telemetry(telemetryRow);
808
841
  log(`turn ended (exit ${realExit}${effExit !== realExit ? ` → effective ${effExit} (${lastEmptyOutput ? "empty-output" : "auth"})` : ""}, ${((Date.now() - t0) / 1000).toFixed(0)}s)`);
@@ -944,16 +977,6 @@ function isContract(message) {
944
977
  return message?.kind === "contract" || /^\s*contract\s*:/i.test(text) || CARD_REF_RE.test(text);
945
978
  }
946
979
 
947
- function isRunnerSession(session) {
948
- const suffix = `:${PROJ}`;
949
- const name = String(session || "");
950
- if (!name.endsWith(suffix)) return false;
951
- const label = name.slice(0, -suffix.length);
952
- // Crew labels are CLI/provider slugs. Host sessions keep their machine-style identity and remain
953
- // valid direct assigners; runner-to-runner prose needs `contract:` or a card reference.
954
- return /^[a-z0-9_.-]+$/.test(label) && !label.startsWith("hub:");
955
- }
956
-
957
980
  // A hub staleness alert describes a moment, so it EXPIRES; a peer's message never does, because a
958
981
  // seat missing a teammate's request is the failure this bus exists to prevent.
959
982
  const HUB_ALERT_TTL_MS = Number(process.env.TRANTOR_HUB_ALERT_TTL_MS || 30 * 60_000);
@@ -969,12 +992,12 @@ function shouldWake(message) {
969
992
  // into the next turn's prompt like a broadcast and never buys a CLI session of its own.
970
993
  if (message?.wake === false) return false;
971
994
  if (message?.to === SESSION) {
995
+ // #7766: with wake unset or true the sender's flag IS the decision. No keyword, card or
996
+ // sender-shape heuristic on the body second-guesses a direct message any more, so a plain
997
+ // question buys its turn. kind:status stays chatter; what is still demoted tells the sender
998
+ // (see the demotion notice at the wake split).
972
999
  if (message?.kind === "status") return false;
973
- // Safety net for senders that never set the flag: a direct message carrying no card and no
974
- // instruction is context. Typed alerts and overseer warnings still wake (#5760).
975
- const typed = message?.kind === "alert" || /^🤝 OVERSEER /.test(String(message?.text || ""));
976
- if (!typed && !isContract(message) && !carriesWork(message?.text)) return false;
977
- return !isRunnerSession(message?.from) || isContract(message);
1000
+ return true;
978
1001
  }
979
1002
  return message?.to === "all"
980
1003
  && isContract(message)
@@ -1106,6 +1129,20 @@ function askedExcerpt(message) {
1106
1129
  const rest = msgs.filter(m => !fyi.includes(m));
1107
1130
  const direct = rest.filter(m => m.to === SESSION && shouldWake(m));
1108
1131
  const mentions = rest.filter(m => m.to === "all" && shouldWake(m));
1132
+ // #7766: a direct message that is STILL demoted to context tells its SENDER in one line, so a
1133
+ // dropped ask is visible instead of looking like a dead seat. Two demotions stay silent: the
1134
+ // sender's own wake:false, and kind:status chatter — this notice is itself kind status, so
1135
+ // answering one would loop. What remains today is the expired hub alert.
1136
+ const demotedSenders = new Set();
1137
+ for (const m of rest) {
1138
+ if (m.to !== SESSION || direct.includes(m) || m.wake === false || m.kind === "status") continue;
1139
+ const who = String(m.from || "");
1140
+ if (!who || who === SESSION || demotedSenders.has(who)) continue;
1141
+ demotedSenders.add(who);
1142
+ log(`\x1b[33mdirect message demoted to context\x1b[0m — telling ${who}`);
1143
+ api("/send", { from: SESSION, to: who, project: senderProjectOf(who) || PROJ, kind: "status",
1144
+ text: `ℹ️ ${SESSION} did not turn on your direct message — demoted to context: "${askedExcerpt(m)}". Re-send citing a card if it still needs a turn` }).catch(() => {});
1145
+ }
1109
1146
  // #6134: messages that do not earn a turn still become context, including direct acks.
1110
1147
  // #7430: stale hub alerts are shed HERE too, not only at boot — one that arrives already past
1111
1148
  // its TTL must not sit in every prompt until the next successful turn clears the batch.
@@ -1313,6 +1350,24 @@ function askedExcerpt(message) {
1313
1350
  await notifyAssigners(assigners,
1314
1351
  `⚠️ your contract FAILED on ${SESSION} (exit ${ec}, ${reason}) · retrying in ${Math.round(wait / 1000)}s · asked: "${asked}"`);
1315
1352
  log(`\x1b[31m${pendingWake.length} message(s) still UNDELIVERED — next attempt in ${Math.round(wait / 1000)}s\x1b[0m`);
1353
+ } else if (lastEmptyTurn) {
1354
+ // #7759: the turn exited 0 but was HOLLOW — a banner is not work. The wake is NOT
1355
+ // consumed: the queue is kept and the ladder retries, and the assigner hears EMPTY,
1356
+ // never "done". Second hollow attempt in a row parks the seat, like a failed turn.
1357
+ deliveryFails++;
1358
+ savePending(pendingWake, pendingBcast);
1359
+ if (deliveryFails >= 2) {
1360
+ retryAt = await parkSeat("empty-turn", pendingWake.length);
1361
+ await notifyAssigners(assigners,
1362
+ `⛔ your contract is PARKED on ${SESSION} (empty-turn: two exit-0 turns with no worktree change, substantive output or bus activity) · asked: "${asked}"`);
1363
+ lastTurnAt = Date.now();
1364
+ return;
1365
+ }
1366
+ const wait = RETRY_MS[Math.min(deliveryFails - 1, RETRY_MS.length - 1)];
1367
+ retryAt = Date.now() + wait;
1368
+ log(`\x1b[33mturn was EMPTY — wake not consumed, ${pendingWake.length} message(s) stay owed; retrying in ${Math.round(wait / 1000)}s\x1b[0m`);
1369
+ await notifyAssigners(assigners,
1370
+ `🫥 EMPTY turn on ${SESSION} (exit 0, ${secs}s — no worktree change, no substantive output, no bus activity) · wake stays owed · retrying in ${Math.round(wait / 1000)}s · asked: "${asked}"`);
1316
1371
  } else {
1317
1372
  pendingWake = []; pendingBcast = []; deliveryFails = 0; retryAt = 0;
1318
1373
  savePending([], []);
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ // `trantor harvest <seat-sha> <main-sha> [--card N]` (#7748): the receipt for a hand-made cherry-pick
3
+ // or squash. The receipt is what lets `trantor sync` realign the seat branch later.
4
+ import { resolveProject, hostId } from "../lib/project.mjs";
5
+ import { recordHarvest, receiptsPath, run } from "../lib/harvest.mjs";
6
+ import { signedPost } from "../hooks/lib/api.mjs";
7
+
8
+ const args = process.argv.slice(2);
9
+ const flag = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : ""; };
10
+ const positional = args.filter((a, i) => !a.startsWith("--") && !(i > 0 && args[i - 1].startsWith("--") && !args[i - 1].startsWith("--no-")));
11
+ const [seatArg, mainArg] = positional;
12
+ if (!seatArg || !mainArg || args.includes("--help")) {
13
+ console.error("usage: trantor harvest <seat-sha> <main-sha> [--card N] [--seat <agent>] [--project <p>] [--no-note]");
14
+ process.exit(seatArg && mainArg ? 0 : 1);
15
+ }
16
+
17
+ const repo = process.cwd();
18
+ const project = flag("project") || resolveProject(repo);
19
+ const resolve = (s) => run(repo, ["rev-parse", "--verify", "-q", `${s}^{commit}`]).out || String(s).trim();
20
+ const seat = resolve(seatArg), main = resolve(mainArg);
21
+ if (!run(repo, ["rev-parse", "--verify", "-q", `${mainArg}^{commit}`]).ok) {
22
+ console.error(`harvest: ${mainArg} is not a commit in ${repo}`);
23
+ process.exit(1);
24
+ }
25
+ let branch = flag("seat") ? `seat/${flag("seat")}` : "";
26
+ if (!branch) {
27
+ const holders = run(repo, ["branch", "--list", "seat/*", "--contains", seat, "--format=%(refname:short)"]).out;
28
+ branch = holders.split("\n").filter(Boolean)[0] || "";
29
+ }
30
+
31
+ let receipt;
32
+ try { receipt = recordHarvest(project, { seat, main, card: flag("card"), branch, by: "harvest" }); }
33
+ catch (e) { console.error(`harvest: ${e.message}`); process.exit(1); }
34
+ const line = `harvested ${receipt.seat.slice(0, 7)} as ${receipt.main.slice(0, 7)}`;
35
+ console.log(`${line}${branch ? ` (${branch})` : ""}${receipt.card ? ` · card #${receipt.card}` : ""} → ${receiptsPath(project)}`);
36
+
37
+ if (receipt.card && !args.includes("--no-note")) {
38
+ const me = `${hostId()}:${project}`;
39
+ const r = await signedPost("/task/update", { id: receipt.card, note: line, by: me, project }, { session: me, project });
40
+ if (r.ok) console.log(`card #${receipt.card} noted: ${line}`);
41
+ else console.error(`warning: card #${receipt.card} not noted (${r.status || r.reason || "hub unreachable"}); the receipt is recorded`);
42
+ }
package/bin/integrate.mjs CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  // `trantor integrate` — collect the crew's work, prove it, ship it.
3
- //
4
3
  // The steps are the ones the orchestrator already performs by hand. The dials decide how far it is
5
4
  // allowed to go on its own, and every stop says which dial stopped it, so "why didn't it push"
6
5
  // always has an answer.
7
6
  import { resolveProject } from "../lib/project.mjs";
8
7
  import { resolveAutonomy } from "../lib/autonomy.mjs";
9
8
  import { seatWorktrees, commitSeatWork, seatAhead, mergeSeat, verify, git } from "../lib/integrate.mjs";
9
+ import { recordHarvest } from "../lib/harvest.mjs";
10
10
 
11
11
  const D = "\x1b[2m", B = "\x1b[1m", G = "\x1b[32m", Y = "\x1b[33m", RED = "\x1b[31m", R = "\x1b[0m";
12
12
  const args = process.argv.slice(2);
@@ -53,7 +53,14 @@ for (const w of seats) {
53
53
  blocked = true;
54
54
  continue;
55
55
  }
56
- if (m.merged) { console.log(` ${G}merged ${branch} (${ahead} commit(s))${R}`); merged.push(branch); }
56
+ if (m.merged) {
57
+ console.log(` ${G}merged ${branch} (${ahead} commit(s))${R}`);
58
+ merged.push(branch);
59
+ // #7748: the receipt `trantor sync` reads later; a merge keeps the seat shas, so this one is
60
+ // the seat tip -> the merge commit.
61
+ try { recordHarvest(project, { seat: git(w.dir, ["rev-parse", branch]), main: git(repo, ["rev-parse", "HEAD"]), branch, by: "integrate" }); }
62
+ catch (e) { console.log(` ${Y}${branch}: receipt not written (${e.message})${R}`); }
63
+ }
57
64
  }
58
65
 
59
66
  if (blocked) {
package/bin/sync.mjs ADDED
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ // `trantor sync [<seat>]` (#7748): realign a seat branch with main from the harvest receipts.
3
+ // Every commit main lacks must carry a receipt; otherwise the sync refuses and names them.
4
+ import { existsSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { resolveProject, busDir } from "../lib/project.mjs";
7
+ import { syncSeat, receiptsPath } from "../lib/harvest.mjs";
8
+
9
+ const D = "\x1b[2m", G = "\x1b[32m", Y = "\x1b[33m", RED = "\x1b[31m", R = "\x1b[0m";
10
+ const args = process.argv.slice(2);
11
+ const flag = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : ""; };
12
+ const seat = args.find((a, i) => !a.startsWith("--") && !(i > 0 && args[i - 1] === "--project"));
13
+ if (args.includes("--help")) {
14
+ console.log("usage: trantor sync [<seat>] [--project <p>] [--dry-run] [--no-fetch] (no seat: the worktree you are in)");
15
+ process.exit(0);
16
+ }
17
+
18
+ const project = flag("project") || resolveProject(process.cwd());
19
+ const dir = seat ? join(busDir(), "worktrees", project, seat) : process.cwd();
20
+ if (seat && !existsSync(join(dir, ".git"))) {
21
+ console.error(`sync: no worktree for seat '${seat}' at ${dir}`);
22
+ process.exit(1);
23
+ }
24
+
25
+ const r = syncSeat(dir, { dryRun: args.includes("--dry-run"), fetch: !args.includes("--no-fetch"), project });
26
+ const short = (s) => String(s || "").slice(0, 7);
27
+ if (r.refused) {
28
+ console.log(`${RED}sync refused${R} · ${r.branch} carries ${r.unharvested.length} commit(s) ${r.target.ref} does not, with no harvest receipt:`);
29
+ for (const c of r.unharvested) console.log(` ${short(c.sha)} ${c.subject}`);
30
+ if (r.harvested.length) console.log(`${D}${r.harvested.length} other commit(s) are receipted and would be dropped once these are.${R}`);
31
+ console.log(`${D}Harvest them (trantor harvest <seat-sha> <main-sha> --card N) or park them on another branch first. Receipts: ${receiptsPath(project)}${R}`);
32
+ process.exit(1);
33
+ }
34
+ if (!r.ok) { console.error(`${RED}sync failed${R}: ${r.reason}`); process.exit(1); }
35
+ if (r.noop) { console.log(`${D}${r.branch} already at ${r.target.ref} (${short(r.head)})${R}`); process.exit(0); }
36
+ const dropped = r.harvested.map(c => `${short(c.sha)}→${short(c.receipt.main)}`).join(", ");
37
+ if (r.dry) {
38
+ console.log(`${Y}[dry]${R} would move ${r.branch} ${short(r.head)} → ${r.target.ref} ${short(r.target.sha)}${dropped ? ` ${D}(receipted: ${dropped})${R}` : ""}`);
39
+ process.exit(0);
40
+ }
41
+ console.log(`${G}synced${R} ${r.branch} ${short(r.from)} → ${r.target.ref} ${short(r.to)}${dropped ? ` ${D}(receipted: ${dropped})${R}` : ""}`);
package/hooks/lib/api.mjs CHANGED
@@ -10,6 +10,7 @@ import { loadOrCreate, loadOrCreateInstance } from "../../lib/identity.mjs";
10
10
  import { sfetchJson } from "../../lib/signed-fetch.mjs";
11
11
 
12
12
  export const DEFAULT_TIMEOUT_MS = 1500;
13
+ export const WRITE_TIMEOUT_MS = 8000; // #7755: writes get their own budget above the read default
13
14
 
14
15
  // Hub URL, PER-PROJECT (TDD §12.1): env RELAY_URL, then config `hubs[project]`, then the legacy global
15
16
  // `url`, then the local default. Never throws, keeping hooks fail-open.
@@ -175,12 +176,25 @@ export async function signedGet(pathOrUrl, { timeoutMs = DEFAULT_TIMEOUT_MS, ses
175
176
  }
176
177
 
177
178
  // Signed POST → { ok, status, json|null }. Never throws.
178
- export async function signedPost(pathOrUrl, payload, { timeoutMs = DEFAULT_TIMEOUT_MS, session, instance, project } = {}) {
179
+ // #7755: writes keep their own timeout, and a write carrying `_op` resolves a timeout through
180
+ // one GET /_op — committed returns the stored body as success, rejected returns the stored refusal,
181
+ // unknown reads "ambiguous: timed out, outcome unknown". Never a bare failure for a write that landed.
182
+ export async function signedPost(pathOrUrl, payload, { timeoutMs = WRITE_TIMEOUT_MS, session, instance, project } = {}) {
179
183
  const proj = projectOf(project, payload, pathOrUrl);
180
184
  const sess = session || sessionFor(proj);
181
185
  const durable = loadIdentity(sess);
182
186
  const id = instance ? loadInstance(sess, instance) : durable;
183
187
  await ensureEnrolled(sess, durable, proj); // instances never enroll — the DURABLE key does
188
+ const resolveOp = async (f) => {
189
+ const op = String(payload?._op || "");
190
+ if (!f.timedOut || !op) return f;
191
+ const base = new URL(toUrl(pathOrUrl, proj)).origin;
192
+ const q = await signedGet(`${base}/_op?id=${encodeURIComponent(op)}`, { session: sess, instance, project: proj });
193
+ const st = q.ok ? q.json?.status : "unknown";
194
+ if (st === "committed") return { ok: true, status: q.json.code || 200, json: q.json.body, resolved: "committed" };
195
+ if (st === "rejected") return { ok: false, status: q.json.code || 400, json: q.json.body, resolved: "rejected" };
196
+ return { ...f, ambiguous: true, reason: "ambiguous: timed out, outcome unknown" };
197
+ };
184
198
  try {
185
199
  // sfetchJson (FROZEN) stringifies the payload + signs with `id` in one call — the single shape
186
200
  // every client uses (lib/signed-fetch.mjs). We pass our memoised identity so it doesn't re-load.
@@ -200,5 +214,5 @@ export async function signedPost(pathOrUrl, payload, { timeoutMs = DEFAULT_TIMEO
200
214
  const text = await r.text();
201
215
  let json = null; try { json = text ? JSON.parse(text) : null; } catch {}
202
216
  return { ok: true, status: r.status, json };
203
- } catch (e) { return failure(e, timeoutMs); }
217
+ } catch (e) { return resolveOp(failure(e, timeoutMs)); }
204
218
  }
@@ -0,0 +1,53 @@
1
+ // #7750 — seat-side hollow-move check: the hub is remote and cannot see a seat's worktree, so on
2
+ // a move to testing/done mcp.mjs diffs the worktree against the sha recorded at `doing`, reads
3
+ // checklist ticks and scans the note for a test command. All missing + no declared no-code
4
+ // outcome → the move still lands, note prefixed HOLLOW:, assigner told on the bus.
5
+ import { execFileSync } from "node:child_process";
6
+ import { readFileSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+
9
+ // A "test command" is a command shape or a pass count — the note contract already demands both.
10
+ const TEST_CMD_RE = /(node\s+test\/|npm\s+(run\s+)?test|pnpm\s+test|yarn\s+test|vitest|pytest|go\s+test|cargo\s+test|make\s+test|\b\d+\s*\/\s*\d+\b|\b\d+\s+passed\b)/i;
11
+ // Declared no-code outcomes (#7750: docs, investigation, refusal, answered on the bus) — a card
12
+ // that says one of these is NEVER flagged, however empty its worktree is.
13
+ const NOCODE_RE = /\b(no[- ]code|docs?[- ]only|investigat\w*|read[- ]only|research[- ]only|analysis[- ]only|answered (on|in) the bus|refus\w+|declin\w+|won'?t fix)\b/i;
14
+
15
+ export const hasTestCommand = (note) => TEST_CMD_RE.test(String(note || ""));
16
+ export const declaresNoCode = (note) => NOCODE_RE.test(String(note || ""));
17
+
18
+ function git(cwd, args) {
19
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
20
+ }
21
+
22
+ // Per-worktree, keyed by card id: <git-dir> is the worktree's OWN dir under a linked worktree
23
+ // (…/.git/worktrees/<seat>), so seats never share each other's bases.
24
+ const basePath = (cwd) => join(git(cwd, ["rev-parse", "--git-dir"]), "hollow-base.json");
25
+
26
+ // Snapshot HEAD when a card is taken (doing); the testing/done check diffs against this sha.
27
+ export function markDoing(cwd, id) {
28
+ try {
29
+ const p = basePath(cwd);
30
+ let map = {};
31
+ try { map = JSON.parse(readFileSync(p, "utf8")); } catch { /* first card in this worktree */ }
32
+ map[id] = git(cwd, ["rev-parse", "HEAD"]);
33
+ writeFileSync(p, JSON.stringify(map));
34
+ } catch { /* not a git worktree — no base recorded, and hollowVerdict fails open */ }
35
+ }
36
+
37
+ // Verdict for a testing/done move. checked:false = no usable base (pre-#7750 card, non-git cwd,
38
+ // base sha lost to a rebase) — fail open rather than flag on a base we cannot trust.
39
+ export function hollowVerdict(cwd, id, note, checklist) {
40
+ let base;
41
+ try { base = JSON.parse(readFileSync(basePath(cwd), "utf8"))[id]; } catch { return { checked: false, hollow: false, missing: [] }; }
42
+ if (!base) return { checked: false, hollow: false, missing: [] };
43
+ const missing = [];
44
+ try {
45
+ // `git diff <base>` covers committed AND uncommitted tracked changes vs the taken-sha.
46
+ if (!git(cwd, ["diff", "--name-only", base, "--"])) missing.push("diff");
47
+ if (!git(cwd, ["ls-files", "--others", "--exclude-standard"])) missing.push("new files");
48
+ } catch { return { checked: false, hollow: false, missing: [] }; }
49
+ if (!(checklist || []).some((c) => c.done)) missing.push("ticked checklist items");
50
+ if (!hasTestCommand(note)) missing.push("test command in the note");
51
+ const hollow = missing.length === 4 && !declaresNoCode(note);
52
+ return { checked: true, hollow, missing };
53
+ }
package/hub.mjs CHANGED
@@ -88,6 +88,28 @@ const overseer = createOverseer({
88
88
  });
89
89
  store.startChangeSubscription(() => events.pushEventToStreams({ ts: Date.now(), type: "hub.reload", project: "", by: "" }));
90
90
 
91
+ // #7755: a write carrying `_op` replays its stored outcome on a retry, and GET /_op answers
92
+ // committed / rejected / unknown, so a client timeout never has to guess whether the write landed.
93
+ const opOutcomes = new Map();
94
+ const OP_OUTCOME_WINDOW_MS = 3600000;
95
+ setInterval(() => {
96
+ const cutoff = Date.now() - OP_OUTCOME_WINDOW_MS;
97
+ for (const [id, o] of opOutcomes) { if (o.ts < cutoff) opOutcomes.delete(id); }
98
+ }, 60000).unref?.();
99
+ const opIdOf = (b) => String(b?._op || "").slice(0, 80) || null;
100
+ // Only a definite answer is stored: 2xx is committed, 4xx is rejected, a 5xx stays unknown.
101
+ function recordOpOutcome(res, op) {
102
+ const end = res.end.bind(res);
103
+ res.end = (chunk, ...rest) => {
104
+ const code = res.statusCode;
105
+ if (code < 500 && !opOutcomes.has(op)) {
106
+ let body = null; try { body = chunk ? JSON.parse(String(chunk)) : null; } catch {}
107
+ opOutcomes.set(op, { ts: Date.now(), code, status: code < 400 ? "committed" : "rejected", body });
108
+ }
109
+ return end(chunk, ...rest);
110
+ };
111
+ }
112
+
91
113
  let UI = "";
92
114
  try { UI = readFileSync(new URL("./ui.html", import.meta.url), "utf8"); } catch {}
93
115
  const context = {
@@ -111,6 +133,14 @@ const server = http.createServer(async (req, res) => {
111
133
  if (!auth.ok) return authRuntime.json(res, auth.code || 401, { error: auth.error || "unauthorized" });
112
134
  const authorization = authRuntime.authorize(auth, req.method, P, authRuntime.projectFromRequest(P, q, b0));
113
135
  if (!authorization.ok) return authRuntime.json(res, authorization.code || 403, { error: authorization.error || "forbidden" });
136
+ if (req.method === "GET" && P === "/_op") {
137
+ const o = opOutcomes.get(String(q.id || "").slice(0, 80));
138
+ return authRuntime.json(res, 200, o ? { status: o.status, code: o.code, body: o.body } : { status: "unknown" });
139
+ }
140
+ const op = req.method === "POST" ? opIdOf(b0) : null;
141
+ const seen = op ? opOutcomes.get(op) : null;
142
+ if (seen) return authRuntime.json(res, seen.code, seen.body);
143
+ if (op) recordOpOutcome(res, op);
114
144
  for (const route of routes) if (await route({ req, res, q, P, auth, ctx: context })) return;
115
145
  authRuntime.json(res, 404, { error: "not found" });
116
146
  } catch (error) {
@@ -37,6 +37,24 @@ export function stripPromptEcho(errText, promptText) {
37
37
  * printed its whole failure in under a hundred characters and produced nothing else. */
38
38
  export const OWN_OUTPUT_ANSWER_MIN = 400;
39
39
 
40
+ // ---- the hollow turn (#7759): exit 0, no worktree change, no substantive output ---------------
41
+ // A CLI banner is bytes on a stream, not work. ANSI stripped, a line is CHROME when it is a pure
42
+ // separator, decorative box/block art, or a label a CLI prints about itself (session, model,
43
+ // provider, workdir, version, tokens) — metadata about the run, never the run's answer.
44
+ const BANNER_RE = /[\u2500-\u257F\u2580-\u259F\u25A0-\u25FF]/;
45
+ const SEPARATOR_RE = /^[-=*_~.#]{3,}$/;
46
+ const CHROME_LABEL_RE = /^(?:session|model|provider|workdir|directory|version|tokens|thinking|truncating|truncated|hint|tip|ctrl|openai|opencode|codex|claude|gemini|kimi|deepseek|glm|qwen|dsh)\b/i;
47
+ export const SUBSTANTIVE_MIN = 120;
48
+
49
+ /** Whether the CLI's echo-stripped output carries anything beyond banner and hook noise: the
50
+ * surviving signal lines must total at least SUBSTANTIVE_MIN chars — a sentence or two. A real
51
+ * answer clears this easily; a banner contributes nothing once its chrome is dropped (#7759). */
52
+ export function substantiveOutput(ownText) {
53
+ const lines = String(ownText || "").replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").split("\n").map(l => l.trim()).filter(Boolean);
54
+ const signal = lines.filter(l => !BANNER_RE.test(l) && !SEPARATOR_RE.test(l) && !CHROME_LABEL_RE.test(l));
55
+ return signal.join("\n").length >= SUBSTANTIVE_MIN;
56
+ }
57
+
40
58
  /** The #5405 rule, refined twice by #5868: exit 0 + an auth-shaped marker means FAILED only when
41
59
  * the CLI's own output is short enough to be JUST the error — and NEVER when the turn did real
42
60
  * work (newCommit, checked by the runner via git): a shipped commit is a live turn, whatever the
@@ -49,20 +67,22 @@ export function looksLikeAuthDeath(ownText, realWork = false) {
49
67
  /** The one-line verdict the seat's jsonl carries (#5868): why the runner judged the turn the way
50
68
  * it did, phrased exactly like the runner's own "classified X because Y" log — so a pane that
51
69
  * scrolls away loses nothing that the telemetry row needs to say. */
52
- export function verdictFor(realExit, effExit, emptyOutput, ownText) {
53
- if (realExit === 0 && effExit === 0) return "classified success because exit 0 with CLI output";
70
+ export function verdictFor(realExit, effExit, emptyOutput, ownText, emptyTurn = false) {
71
+ if (realExit === 0 && effExit === 0) return emptyTurn
72
+ ? "classified empty-turn because exit 0 with no worktree change, no substantive output, no bus activity"
73
+ : "classified success because exit 0 with CLI output";
54
74
  if (realExit === 0 && effExit === 1) {
55
75
  if (emptyOutput) return "classified empty-output because exit 0 with no output on either stream";
56
76
  const m = AUTH_MARKER_RE.exec(String(ownText || ""));
57
77
  return `classified auth because ${m ? m[0] : "auth marker"} in the CLI's own short output`;
58
78
  }
59
- const { reason, matched } = classifyFailure(realExit, String(ownText || ""), emptyOutput);
79
+ const { reason, matched } = classifyFailure(realExit, String(ownText || ""), emptyOutput, emptyTurn);
60
80
  return `classified ${reason} because ${matched}`;
61
81
  }
62
82
 
63
83
  /** Classify one failed turn. Returns { reason, matched } — matched is the evidence excerpt the
64
84
  * runner logs, so the next misclassification is diagnosable from the seat log alone. */
65
- export function classifyFailure(exit, errText, emptyOutput = false) {
85
+ export function classifyFailure(exit, errText, emptyOutput = false, emptyTurn = false) {
66
86
  // #5481: silence with a clean exit is a failure shape, not success — see lastEmptyOutput.
67
87
  if (emptyOutput) return { reason: "empty-output", matched: "exit 0 with no output on either stream" };
68
88
  const t = String(errText || "").toLowerCase();
@@ -79,6 +99,10 @@ export function classifyFailure(exit, errText, emptyOutput = false) {
79
99
  if (m) return { reason: "exhausted", matched: m[0] };
80
100
  m = t.match(/unauthor|401|invalid[ _-]?api[ _-]?key|forbidden|403|token expired|expired/);
81
101
  if (m) return { reason: "auth", matched: m[0] };
102
+ // #7759: exit 0 with bytes on the stream but neither a changed worktree nor substantive output
103
+ // is a HOLLOW turn — the CLI printed its banner and quit. It ranks BELOW every real failure
104
+ // shape above: an auth-shaped or exhausted exit is that failure, never an empty turn.
105
+ if (emptyTurn) return { reason: "empty-turn", matched: "exit 0 with no worktree change, no substantive output, no bus activity" };
82
106
  return { reason: "crashed", matched: `exit ${exit} with no known failure pattern` };
83
107
  }
84
108
 
@@ -0,0 +1,97 @@
1
+ // Harvest receipts (#7748): a seat commit landed on main under a NEW sha (cherry-pick, squash)
2
+ // diverges the seat branch for good. The receipt records seat sha -> main sha -> card so
3
+ // `trantor sync` can realign the seat without anyone re-proving patch-equivalence.
4
+ import { spawnSync } from "node:child_process";
5
+ import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
6
+ import { join, dirname } from "node:path";
7
+ import { busDir } from "./project.mjs";
8
+
9
+ const SHA_RE = /^[0-9a-f]{7,40}$/;
10
+
11
+ export function receiptsPath(project, root = busDir()) {
12
+ return join(root, `harvest-${project}.json`);
13
+ }
14
+
15
+ export function readReceipts(project, root = busDir()) {
16
+ const p = receiptsPath(project, root);
17
+ if (!existsSync(p)) return [];
18
+ try {
19
+ const j = JSON.parse(readFileSync(p, "utf8"));
20
+ return Array.isArray(j?.receipts) ? j.receipts : [];
21
+ } catch { return []; }
22
+ }
23
+
24
+ // A hand-typed receipt carries a short sha, so either side may be a prefix of the other.
25
+ export function shaMatches(a, b) {
26
+ const x = String(a || "").toLowerCase(), y = String(b || "").toLowerCase();
27
+ return x.length >= 7 && y.length >= 7 && (x.startsWith(y) || y.startsWith(x));
28
+ }
29
+
30
+ export function recordHarvest(project, { seat, main, card = 0, branch = "", by = "" }, root = busDir()) {
31
+ const seatSha = String(seat || "").trim().toLowerCase();
32
+ const mainSha = String(main || "").trim().toLowerCase();
33
+ if (!SHA_RE.test(seatSha) || !SHA_RE.test(mainSha)) throw new Error("harvest: a sha is 7 to 40 hex characters");
34
+ const list = readReceipts(project, root).filter(r => !shaMatches(r.seat, seatSha));
35
+ const receipt = { seat: seatSha, main: mainSha, card: Number(card) || 0, branch: String(branch || ""), by: String(by || ""), ts: Date.now() };
36
+ list.push(receipt);
37
+ const p = receiptsPath(project, root);
38
+ mkdirSync(dirname(p), { recursive: true });
39
+ const tmp = `${p}.${process.pid}.tmp`;
40
+ writeFileSync(tmp, JSON.stringify({ project, receipts: list }, null, 2) + "\n");
41
+ renameSync(tmp, p);
42
+ return receipt;
43
+ }
44
+
45
+ export function run(cwd, args, { timeout = 15000 } = {}) {
46
+ const r = spawnSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout });
47
+ return { ok: r.status === 0, out: String(r.stdout || "").trim(), err: String(r.stderr || "").trim() };
48
+ }
49
+ const out = (cwd, args) => { const r = run(cwd, args); return r.ok ? r.out : ""; };
50
+
51
+ // The runner persists branch.<seat>.base at worktree creation; a worktree made by hand means main.
52
+ export function seatBase(dir, branch) {
53
+ return out(dir, ["config", "--get", `branch.${branch}.base`]) || "main";
54
+ }
55
+
56
+ // Realignment target: the remote base when origin carries it, else the local base branch.
57
+ export function syncTarget(dir, base) {
58
+ const remote = out(dir, ["rev-parse", "--verify", "-q", `refs/remotes/origin/${base}`]);
59
+ if (remote) return { ref: `origin/${base}`, sha: remote };
60
+ const local = out(dir, ["rev-parse", "--verify", "-q", `refs/heads/${base}`]);
61
+ return { ref: base, sha: local };
62
+ }
63
+
64
+ // Every commit the seat branch carries that the target does not, split by whether a receipt covers it.
65
+ export function auditSeat(dir, { branch, targetRef, receipts }) {
66
+ const log = out(dir, ["log", "--reverse", "--format=%H%x09%s", `${targetRef}..${branch}`]);
67
+ const harvested = [], unharvested = [];
68
+ for (const line of log.split("\n").filter(Boolean)) {
69
+ const [sha, ...rest] = line.split("\t");
70
+ const subject = rest.join("\t");
71
+ const receipt = receipts.find(r => shaMatches(r.seat, sha));
72
+ (receipt ? harvested : unharvested).push({ sha, subject, receipt });
73
+ }
74
+ return { harvested, unharvested };
75
+ }
76
+
77
+ // Refuses rather than loses: an unharvested commit stops the sync and is named. `reset --keep`
78
+ // carries uncommitted edits across and refuses on its own when one touches a file main changed.
79
+ export function syncSeat(dir, { branch = "", dryRun = false, fetch = true, receipts, root = busDir(), project = "" } = {}) {
80
+ const current = out(dir, ["rev-parse", "--abbrev-ref", "HEAD"]);
81
+ if (!current || current === "HEAD") return { ok: false, reason: `${dir} has no branch checked out (detached HEAD)` };
82
+ if (branch && branch !== current) return { ok: false, reason: `${dir} is on ${current}, not ${branch}` };
83
+ branch = current;
84
+ const base = seatBase(dir, branch);
85
+ if (fetch) run(dir, ["fetch", "-q", "origin", base], { timeout: 20000 });
86
+ const target = syncTarget(dir, base);
87
+ if (!target.sha) return { ok: false, reason: `no ${base} to sync to: neither origin/${base} nor a local ${base} exists` };
88
+ const audit = auditSeat(dir, { branch, targetRef: target.sha, receipts: receipts || readReceipts(project, root) });
89
+ const head = out(dir, ["rev-parse", "HEAD"]);
90
+ const result = { ok: true, branch, base, target, head, ...audit };
91
+ if (audit.unharvested.length) return { ...result, ok: false, refused: true, reason: `${audit.unharvested.length} unharvested commit(s) on ${branch} would be lost` };
92
+ if (head === target.sha) return { ...result, noop: true };
93
+ if (dryRun) return { ...result, dry: true };
94
+ const r = run(dir, ["reset", "--keep", target.sha]);
95
+ if (!r.ok) return { ...result, ok: false, reason: `git reset --keep refused: ${r.err || r.out}`.trim() };
96
+ return { ...result, from: head, to: target.sha };
97
+ }
@@ -95,8 +95,9 @@ export function hasImperative(text) {
95
95
  return IMPERATIVE_RE.test(String(text || ""));
96
96
  }
97
97
 
98
- /// The safety net for a sender that never set `wake`: a direct message with no card and no
99
- /// imperative is an ack, an FYI or a queue note, and it batches into the next turn's context.
98
+ /// #7766: no longer a wake gate on a direct message the sender's wake flag decides alone. What
99
+ /// keeps this alive is the receipt side of #7079: a reply threaded onto a seat's own outcome is an
100
+ /// ack only while it carries no work of its own.
100
101
  export function carriesWork(text) {
101
102
  return cardRef(text) > 0 || hasImperative(text);
102
103
  }
package/mcp.mjs CHANGED
@@ -7,6 +7,7 @@ import { writeFileSync, readFileSync, existsSync, mkdirSync } from "node:fs";
7
7
  import { join, basename, dirname } from "node:path";
8
8
  import { homedir, hostname } from "node:os";
9
9
  import { execSync, spawnSync } from "node:child_process";
10
+ import { randomUUID } from "node:crypto";
10
11
  import { createRequire } from "node:module";
11
12
  import { fileURLToPath, pathToFileURL } from "node:url";
12
13
  import { advise } from "./bin/advise.mjs";
@@ -14,15 +15,12 @@ import { resolveProject, hostId, resolveHub, resolveHubInfo, nonSeatReason, hand
14
15
  import { signedPost, signedGet } from "./hooks/lib/api.mjs";
15
16
  import { anchorCursor } from "./hooks/lib/inbox-ledger.mjs";
16
17
  import { assertNoSecrets } from "./lib/scrub.mjs";
18
+ import { markDoing, hollowVerdict } from "./hooks/lib/hollow-move.mjs";
17
19
 
18
- // ---- runtime dep resolution -------------------------------------------------
19
- // `claude plugin install` snapshots the REPO, not an npm tarball, so a GitHub-sourced
20
- // plugin ships no node_modulesand a static `import "@modelcontextprotocol/sdk/..."`
21
- // then dies with ERR_MODULE_NOT_FOUND before a single line runs. The failure is silent
22
- // from the user's side: every relay tool just disappears. So resolve these two ourselves.
23
- // Normal path is untouched (plain `import(spec)`, ESM build, deps present); only when that
24
- // comes back NOT_FOUND do we borrow the tree from the globally installed `trantor`, which
25
- // npm always gives real dependencies at the same version as the plugin.
20
+ // Runtime dep resolution: `claude plugin install` snapshots the repo, not an npm tarball, so a
21
+ // GitHub-sourced plugin ships no node_modules and a static SDK import dies with
22
+ // ERR_MODULE_NOT_FOUND before any line runs silently: every relay tool just disappears.
23
+ // Resolve ourselves: plain import(spec) first; on NOT_FOUND borrow the global trantor's tree.
26
24
  const HERE = dirname(fileURLToPath(import.meta.url));
27
25
  const req = createRequire(import.meta.url);
28
26
 
@@ -75,12 +73,10 @@ const SESSION_AGENT = process.env.RELAY_AGENT || process.env.RELAY_AGENT_FALLBAC
75
73
  const SESSION = process.env.RELAY_SESSION
76
74
  || (SESSION_AGENT ? `${SESSION_AGENT}:${PROJECT}` : `${hostId()}:${PROJECT}`);
77
75
  let cursor = 0;
78
- // First-call guard: a brand-new MCP process must NOT replay the entire historical backlog (observed:
79
- // 2,379 msgs / 520KB back to an old asteroids project) the instant relay_inbox/relay_wait is called.
80
- // The seed anchors to BOOT (this server starts with the session), never to the time of the first call:
81
- // seeding to "now at first call" swallowed #7282 on 2026-08-20 the call came 33 minutes into the
82
- // session, prompted by a nudge ABOUT that message. PEEK only: the hooks own the hub's delivery ledger
83
- // (sessionstart claims the pre-start backlog); a non-peek seed here marked unseen messages delivered.
76
+ // First-call guard: a new MCP process must NOT replay the historical backlog the instant
77
+ // relay_inbox/relay_wait is called (one project got 2,379 msgs / 520KB back). The seed anchors to
78
+ // BOOT, never to the first call a "now" seed swallowed #7282. PEEK only: the hooks own the
79
+ // delivery ledger; a non-peek seed here marked unseen messages delivered.
84
80
  const BOOT_TS = Date.now();
85
81
  let cursorSeeded = false;
86
82
  async function seedCursor() {
@@ -92,23 +88,22 @@ async function seedCursor() {
92
88
  } catch { /* hub down: the subsequent real call surfaces the error; retry the seed next time */ }
93
89
  }
94
90
 
95
- // Every hub call is SIGNED with this session's Ed25519 keypair (TDD §7.3) via the shared client in
96
- // hooks/lib/api.mjs. Writes: that is what binds /send's `from` to the signer and closes the
97
- // self-asserted `from` hole (the 2026-07-28 RCE). Reads TOO (the 2026-07-30 agent-UX gap): an
98
- // enforce hub 401s unsigned reads, which made relay_inbox/board/peers dead for the very agents the
99
- // bus exists for. Signed reads are scope-filtered by the hub to this identity's grants — for a
100
- // session reading its own project + DMs that is the intended behavior. The client fail-opens on a
101
- // down hub (returns {ok:false}); we surface that as a thrown Error so individual tools .catch it.
102
- // Instance id (docs/INSTANCE-KEYS-CONTRACT.md): the MCP server has no harness session_id, so it
103
- // mints a random id at boot — its lifetime ≈ the session's. The endorsed subkey it keys signs all
104
- // traffic; the durable identity keeps enrollment and attribution.
91
+ // Every hub call is SIGNED with this session's Ed25519 keypair (TDD §7.3) via hooks/lib/api.mjs:
92
+ // writes bind /send's `from` to the signer (closing the self-asserted `from` RCE hole), reads stay
93
+ // alive under an enforcing hub and are scope-filtered by the hub to this identity's grants. The
94
+ // client fail-opens on a down hub ({ok:false}); we throw so individual tools .catch it.
95
+
96
+ // Instance id (docs/INSTANCE-KEYS-CONTRACT.md): no harness session_id exists here, so mint a
97
+ // random id at boot its lifetime the session's; the endorsed subkey it keys signs all traffic.
105
98
  const INSTANCE_ID = `mcp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
106
99
  async function api(method, path, payload, { timeoutMs } = {}) {
107
100
  // PROJECT explicitly, never the client's cwd fallback: this server's project is fixed at boot,
108
101
  // and letting the hub be re-derived per call is how a session ends up writing to two hubs.
102
+ // #7755: every write carries an op id; signedPost resolves its own timeout through GET /_op,
103
+ // so a timed-out write comes back committed, rejected, or ambiguous in those words.
109
104
  const r = method.toUpperCase() === "GET"
110
105
  ? await signedGet(path, { session: SESSION, instance: INSTANCE_ID, project: PROJECT, timeoutMs })
111
- : await signedPost(path, payload, { session: SESSION, instance: INSTANCE_ID, project: PROJECT, timeoutMs });
106
+ : await signedPost(path, { ...payload, _op: randomUUID() }, { session: SESSION, instance: INSTANCE_ID, project: PROJECT, timeoutMs });
112
107
  if (!r.ok) {
113
108
  // A timeout is not an outage. `hub 0 on /tasks` read as a DEAD HUB when the hub was 200 OK and
114
109
  // merely slow — /tasks is 1.59MB across 941 cards — and a reader who cannot tell them apart
@@ -126,11 +121,10 @@ const fmt = (m) => `#${m.id} [${m.from} -> ${m.to}] ${new Date(m.ts).toLocaleTim
126
121
  const server = new McpServer({ name: "trantor", version: "0.1.0" });
127
122
 
128
123
  server.tool("relay_whoami", "Show this session's relay identity, project, hub URL, HOW that hub was chosen, and whether this session is actually a registered seat.", {}, async () => {
129
- // This tool used to POST /register before answering so asking "where am I?" from a
130
- // non-project directory CREATED the phantom seat it then reported, on the local hub, and the
131
- // answer looked healthy. A diagnostic must observe, never mutate. It now reports the truth,
132
- // including the two things that actually go wrong: an unregistered session, and a hub that was
133
- // a fallback rather than a pin.
124
+ // A diagnostic must observe, never mutate: this tool used to POST /register before answering, so
125
+ // asking "where am I?" from a non-project directory CREATED the phantom seat it then reported,
126
+ // and the answer looked healthy. It now reports the truth an unregistered session, or a hub
127
+ // that is a fallback rather than a pin.
134
128
  const { url, via } = resolveHubInfo(PROJECT);
135
129
  const viaText = { env: "RELAY_URL env override", pin: "pinned for this project", global: "GLOBAL DEFAULT — this project is not pinned", default: "BUILT-IN DEFAULT — no config found" }[via] || via;
136
130
  let text = `session=${SESSION}\nproject=${PROJECT}\nhub=${url}\nhub_via=${via} (${viaText})\nregistered=${isHomeDirSession ? "NO" : "yes"}`;
@@ -221,10 +215,30 @@ server.tool("relay_phase_goal", "Set what a PHASE is for — its goal — shown
221
215
  return { content: [{ type: "text", text: `phase "${phase}" goal set for ${proj}` }] };
222
216
  });
223
217
 
224
- server.tool("relay_task_move", "Move a Kanban card as you progress: todo -> doing -> testing -> done. NEVER move straight to done: move to 'testing' when you finish, run the project's tests/typecheck, then 'done' only if green — or 'failed' (with a relay_send explaining what broke) if not. The orchestrator bounces failed cards back to doing. blocked = waiting on something external. A move to 'testing' or 'done' MUST carry a `note` (<=2000 chars): what you changed and the evidence (the test command + counts). The note lands on the card's permanent log — the board shows its ·N count, so a silent move reads as unverified work.",
218
+ server.tool("relay_task_move", "Move a Kanban card as you progress: todo -> doing -> testing -> done. NEVER move straight to done: move to 'testing' when you finish, run the project's tests/typecheck, then 'done' only if green — or 'failed' (with a relay_send explaining what broke) if not. The orchestrator bounces failed cards back to doing. blocked = waiting on something external. A move to 'testing' or 'done' MUST carry a `note` (<=2000 chars): what you changed and the evidence (the test command + counts). The note lands on the card's permanent log — the board shows its ·N count, so a silent move reads as unverified work. A testing/done move with NO worktree diff, no new files, no ticked checklist items and no test command in the note is flagged HOLLOW: and reported to the card's assigner (#7750) — a legitimately no-code card (docs, investigation, refusal, answered on the bus) avoids the flag by declaring that outcome in the note.",
225
219
  { id: z.number(), status: z.enum(["todo","doing","testing","failed","done","blocked"]), note: z.string().max(2000).optional().describe("card-log entry (<=2000 chars) — REQUIRED on moves to testing/done: what changed + the evidence (command, pass counts)") },
226
220
  async ({ id, status, note }) => {
227
- await api("POST", "/task/update", { id, status, note, by: SESSION });
221
+ // #7750: seat-side hollow-move check. The hub cannot see this worktree, so the evidence is
222
+ // gathered HERE at report time; a hollow move still lands (never blocks a no-code card), the
223
+ // note is prefixed HOLLOW: and the card's assigner gets one bus line. Never throws into the move.
224
+ let outNote = note;
225
+ try {
226
+ if (status === "doing") markDoing(process.cwd(), id);
227
+ if (status === "testing" || status === "done") {
228
+ const { task } = await api("GET", `/card?project=${encodeURIComponent(PROJECT)}&id=${id}`);
229
+ const v = hollowVerdict(process.cwd(), id, note, task?.checklist);
230
+ if (v.checked && v.hollow) {
231
+ const what = `no ${v.missing.join(", no ")}`;
232
+ outNote = `HOLLOW: ${what} — ${note || "(no note)"}`.slice(0, 2000);
233
+ const assigner = task?.by;
234
+ if (assigner && assigner !== SESSION) {
235
+ await api("POST", "/send", { from: SESSION, to: assigner, wake: false,
236
+ text: `HOLLOW move on #${id} -> ${status} by ${SESSION}: ${what}. The move landed; the note is flagged on the card.` });
237
+ }
238
+ }
239
+ }
240
+ } catch { /* the flag must never break a move */ }
241
+ await api("POST", "/task/update", { id, status, note: outNote, by: SESSION });
228
242
  return { content: [{ type: "text", text: `card #${id} -> ${status}` }] };
229
243
  });
230
244
 
@@ -328,11 +342,10 @@ server.tool("relay_withdraw_proposal", "Withdraw one of THIS session's PENDING p
328
342
  return { content: [{ type: "text", text: `proposal #${id} withdrawn — one queue slot free` }] };
329
343
  });
330
344
 
331
- // ONE card, not the board (#6134). A seat used to be told to "query the board for related PAST
332
- // cards and lessons 1900+ cards of tribal knowledge", and it did: the whole board, every turn,
333
- // almost all of it other people's work. This is the same intent at a thousandth of the tokens
334
- // the card, what it waits on, its own notes, and the handful of done cards that actually rhyme
335
- // with it. Client-side on /tasks, so no hub change and it works against any hub version.
345
+ // ONE card, not the board (#6134): a seat told to "query the board for related PAST cards and
346
+ // lessons" pulled 1900+ cards of mostly other people's work, every turn. This is the same intent
347
+ // at a thousandth of the tokens the card, what it waits on, its notes, and the done cards that
348
+ // rhyme with it. Client-side on /tasks, so no hub change and it works against any hub version.
336
349
  const STOPWORDS = new Set(["the","a","an","and","or","of","to","in","on","for","with","is","it","its","that","this","not","but","by","at","as","from","into","out","up","down","when","then","than","so","no","new","one","two","every","all","any"]);
337
350
  function titleWords(title) {
338
351
  return new Set(String(title || "").toLowerCase().match(/[a-z][a-z0-9_-]{2,}/g)?.filter(w => !STOPWORDS.has(w)) || []);
@@ -378,11 +391,10 @@ server.tool("relay_board", "Show a project's Kanban board (all cards + their sta
378
391
  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.") },
379
392
  async ({ project, card }) => {
380
393
  const proj = project || PROJECT;
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.
394
+ // #6983: ask for the SLIM projection, and for that one card full when opening one. The board
395
+ // needs titles and a note COUNT, never every card's note text: 1.55MB / 625-961ms against a
396
+ // 1500ms budget is why this tool answered "hub 0" while the hub was 200 OK and merely slow.
397
+ // An older hub ignores both params and returns the full board, which still renders either way.
386
398
  const { tasks } = await api("GET", `/tasks?project=${encodeURIComponent(proj)}&fields=slim${card ? `&card=${encodeURIComponent(card)}` : ""}`);
387
399
  if (!tasks.length) return { content: [{ type: "text", text: `${proj}: no cards yet` }] };
388
400
  if (card) return { content: [{ type: "text", text: cardView(tasks, card, proj) }] };
@@ -405,7 +417,7 @@ server.tool("relay_peers", "Find who you can talk to: the live agent sessions on
405
417
 
406
418
  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.",
407
419
  { to: z.string().describe("target session id, or 'all'"), text: z.string().describe("message body"),
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."),
420
+ 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. With wake unset or true, a direct message ALWAYS earns a turn whatever its text says (#7766) — so set false on every ack, FYI and queue note, not just on obvious chatter."),
409
421
  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
422
  async ({ to, text, wake, re }) => {
411
423
  // The event log is append-only — a secret in it is unrecoverable, so refuse BEFORE
@@ -456,7 +468,7 @@ server.tool("relay_handoff", "Write a rich handoff for THIS session so a fresh s
456
468
  // session_id: WHO wrote this. This server has no harness session id, so it records the orch
457
469
  // thread's id when the evidence says the writer IS that thread (orchWriterSid). Without it a
458
470
  // tool-written orchestrator handoff carries no writer and the baton-hold + map-follow logic in
459
- // sessionstart.mjs can never fire (found live 2026-08-28: trantor-1787886998 had session_id null).
471
+ // sessionstart.mjs can never fire (seen live: an orch handoff carried session_id null).
460
472
  const rec = { id: `${name}-${stamp}`, project, projectName: name, machine: hostname(), trigger: "relay_handoff-tool", session_id: orchWriterSid(project, PROJECT), stamp: Number(stamp) || 0, summary: String(summary), gitStatus: git, consumed: false };
461
473
  writeFileSync(join(dir, `${rec.id}.json`), JSON.stringify(rec, null, 2));
462
474
  await api("POST", "/send", { from: SESSION, to: "all", text: `📋 Handoff ready for ${name} — open a fresh session here to take over (${rec.id}).` }).catch(() => {});
@@ -490,10 +502,8 @@ const HEARTBEAT_MS = Number(process.env.RELAY_HEARTBEAT_MS || 60 * 1000);
490
502
 
491
503
  // This server's own plugin version, stamped on every /register. Only the tool-use heartbeat HOOK
492
504
  // used to write hookVersion, so a fresh-but-idle session's peer row kept its DEAD predecessor's
493
- // version until the first tool call — misread twice on 2026-08-19 as "wrong plugin version after
494
- // restart". The MCP boots with the session and registers immediately, so it makes the row truthful
495
- // from second one. Same lookup as hooks/lib/update-check.mjs: plugin.json beside this file, then
496
- // package.json (running straight from the repo).
505
+ // version until the first tool call — misread as "wrong plugin version after restart". The MCP
506
+ // registers at boot, making the row truthful from second one. Same lookup as update-check.mjs.
497
507
  const MCP_VERSION = (() => {
498
508
  for (const rel of ["./.claude-plugin/plugin.json", "./package.json"]) {
499
509
  try {
@@ -504,45 +514,32 @@ const MCP_VERSION = (() => {
504
514
  return "";
505
515
  })();
506
516
 
507
- // Mirror the SessionStart/PostToolUse hooks: some directories aren't project work, and
508
- // auto-registering from them spawns a phantom project lane that then sits in the sidebar forever.
509
- // PROJECT falls back to the cwd basename, so the directory name becomes the lane name:
510
- // - the home directory itself → a "<username>" lane
511
- // - a plugin-cache snapshot → a lane named after the VERSION, e.g. "0.17.66"
512
- // The second one is not hypothetical: `cd ~/.claude/plugins/cache/trantor/trantor/<ver> &&
513
- // node mcp.mjs` is the documented way to check the relay server still boots after a plugin
514
- // update, and every such check was leaving a version-numbered lane behind.
515
- // Opt in explicitly with RELAY_SESSION or RELAY_PROJECT. The MCP server still starts so the
516
- // user can call relay tools (e.g. relay_whoami) deliberately; we just skip auto-presence.
517
+ // Mirror the SessionStart/PostToolUse hooks: some directories are not project work, and
518
+ // auto-registering from them spawns a phantom project lane that sits in the sidebar forever
519
+ // (home dir a "<username>" lane; a plugin-cache snapshot a VERSION-numbered lane, left
520
+ // behind by every documented post-update boot check). Opt in with RELAY_SESSION/RELAY_PROJECT.
517
521
  const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
518
- // ONE definition of "is this a seat", shared with the SessionStart hook (lib/project.mjs). When
519
- // the hook and this server disagreed, a session in a non-repo directory got the hook's "not a
520
- // seat" verdict AND an MCP registration — a phantom peer on the fallback hub, which is the
521
- // split-brain the whole fix exists to remove. nonSeatReason() also covers a plain non-git
522
- // directory (~/development), the case the old home-dir-only check let through.
522
+ // ONE definition of "is this a seat", shared with the SessionStart hook (lib/project.mjs): when
523
+ // the two disagreed, a session in a non-repo directory got the hook's "not a seat" verdict AND an
524
+ // MCP registration — a phantom peer on the fallback hub, the split-brain this removes.
525
+ // nonSeatReason() also covers a plain non-git directory, which the old home-dir-only check missed.
523
526
  const nonProjectReason = nonSeatReason(projectDir);
524
527
  const isHomeDirSession = !!nonProjectReason;
525
528
 
526
529
  // #6170: WHAT this session is, when it can know. sessionstart stamps kind "orch" on the
527
530
  // orchestrator pane, but that runs once — every MCP beat afterwards was kindless, and the hub's
528
531
  // crew exemption reads the peer row's kind, so the orchestrator kept being demoted by its own
529
- // heartbeat and then warned about as an intruder on its own project. Same test sessionstart uses:
530
- // TRANTOR_ORCH names the project this pane orchestrates.
532
+ // heartbeat, then warned about as an intruder on its own project. Same test sessionstart uses.
531
533
  const KIND = process.env.TRANTOR_ORCH && process.env.TRANTOR_ORCH === PROJECT ? { kind: "orch" } : {};
532
534
 
533
535
  if (!isHomeDirSession) {
534
536
  await api("POST", "/register", { session: SESSION, project: PROJECT, status: `active in ${PROJECT}`, hookVersion: MCP_VERSION, ...KIND })
535
537
  .catch((err) => { process.stderr.write(`[trantor-mcp] initial register failed: ${err?.message || err}\n`); });
536
538
 
537
- // Heartbeat — keep this session's presence fresh for as long as the MCP process lives.
538
- // Registration alone decays after the hub's online window (5 min); without this, idle agents
539
- // and EVERY agent after the laptop sleeps (dead connection, no resume event) fall off the
540
- // board while their process is still alive. This is the UNIVERSAL counterpart to the Claude-only
541
- // PostToolUse heartbeat hook: it runs inside the relay every agent loads (Claude, codex, gemini,
542
- // kimi, deepseek), so the whole crew stays tracked. We POST /register with NO status, so the
543
- // hub refreshes lastSeen but preserves the session's meaningful status. setInterval pauses during
544
- // sleep and fires on wake, so presence self-heals within one interval; .unref() lets the process
545
- // still exit cleanly when the agent closes the stdio transport (no phantom peers).
539
+ // Heartbeat — presence decays after the hub's 5-min online window, so without this idle agents
540
+ // (and every agent after laptop sleep) fall off the board while their process is alive. This is
541
+ // the universal counterpart to the Claude-only PostToolUse hook, running in the relay every
542
+ // agent loads. NO status on the POST (lastSeen refreshes, status preserved); .unref() keeps exit clean.
546
543
  setInterval(() => { api("POST", "/register", { session: SESSION, project: PROJECT, hookVersion: MCP_VERSION, ...KIND }).catch(() => {}); }, HEARTBEAT_MS).unref?.();
547
544
  } else {
548
545
  process.stderr.write(`[trantor-mcp] ${nonProjectReason} — not auto-registering on the bus (set RELAY_SESSION or RELAY_PROJECT to opt in)\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.57",
3
+ "version": "0.18.58",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"