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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/cli.mjs +4 -0
- package/bin/crew-runner.mjs +76 -21
- package/bin/harvest.mjs +42 -0
- package/bin/integrate.mjs +9 -2
- package/bin/sync.mjs +41 -0
- package/hooks/lib/api.mjs +16 -2
- package/hooks/lib/hollow-move.mjs +53 -0
- package/hub.mjs +30 -0
- package/lib/classify-failure.mjs +28 -4
- package/lib/harvest.mjs +97 -0
- package/lib/turn-policy.mjs +3 -2
- package/mcp.mjs +71 -74
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
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)
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -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
|
-
|
|
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([], []);
|
package/bin/harvest.mjs
ADDED
|
@@ -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) {
|
|
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
|
-
|
|
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) {
|
package/lib/classify-failure.mjs
CHANGED
|
@@ -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
|
|
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
|
|
package/lib/harvest.mjs
ADDED
|
@@ -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
|
+
}
|
package/lib/turn-policy.mjs
CHANGED
|
@@ -95,8 +95,9 @@ export function hasImperative(text) {
|
|
|
95
95
|
return IMPERATIVE_RE.test(String(text || ""));
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
///
|
|
99
|
-
///
|
|
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
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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
|
|
79
|
-
// 2,379 msgs / 520KB back
|
|
80
|
-
//
|
|
81
|
-
//
|
|
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
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
//
|
|
101
|
-
//
|
|
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
|
-
//
|
|
130
|
-
// non-project directory CREATED the phantom seat it then reported,
|
|
131
|
-
// answer looked healthy.
|
|
132
|
-
//
|
|
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
|
-
|
|
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)
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
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
|
|
382
|
-
// needs titles and a note COUNT, never every card's note text
|
|
383
|
-
//
|
|
384
|
-
//
|
|
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.
|
|
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 (
|
|
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
|
|
494
|
-
//
|
|
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
|
|
508
|
-
// auto-registering from them spawns a phantom project lane that
|
|
509
|
-
//
|
|
510
|
-
//
|
|
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)
|
|
519
|
-
// the
|
|
520
|
-
//
|
|
521
|
-
//
|
|
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
|
|
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 —
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
//
|
|
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`);
|