trantor 0.18.49 → 0.18.50

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.49",
3
+ "version": "0.18.50",
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": {
@@ -10,13 +10,14 @@
10
10
  //
11
11
  // Env seams (the drill's off switches, same doctrine as TRANTOR_NO_HANDOFF_SPAWN):
12
12
  // TRANTOR_BATON_IDLE_DEADLINE_S give up waiting for idle after this many seconds (default 600)
13
+ // TRANTOR_BATON_AGENT_DROP_MS give herdr this long to retire the ended agent (default 10000)
13
14
  // TRANTOR_BATON_REOPEN command to reopen the pane, instead of `trantor open`
14
15
  // (the drill points this at its own herdr world)
15
16
  // Detached means nobody reads stdout: everything lands in <bus>/logs/baton-pane-<project>.log.
16
17
  import { readFileSync, existsSync, mkdirSync, appendFileSync } from "node:fs";
17
18
  import { join, basename } from "node:path";
18
19
  import { homedir } from "node:os";
19
- import { execFileSync, execSync } from "node:child_process";
20
+ import { execFileSync, spawnSync } from "node:child_process";
20
21
  import { createConnection } from "node:net";
21
22
 
22
23
  const arg = (name) => { const i = process.argv.indexOf(name); return i > 0 ? process.argv[i + 1] : ""; };
@@ -67,8 +68,50 @@ function socketRequest(req, timeoutMs = 30_000) {
67
68
 
68
69
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
69
70
  const agentStatus = (pane) => herdrJson(["agent", "get", pane])?.result?.agent?.agent_status || null;
71
+ const hasAgent = (pane) => Boolean(herdrJson(["agent", "get", pane])?.result?.agent);
70
72
  const alive = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };
71
73
 
74
+ // The shells a pane idles in once its agent is gone. Their pid is never the process to end.
75
+ const SHELL_NAMES = new Set(["zsh", "bash", "sh", "fish", "dash", "ksh", "tcsh", "csh", "nu", "login"]);
76
+ function isShellProcess(p) {
77
+ const raw = String(p?.name || p?.argv0 || "").trim();
78
+ const name = basename(raw.replace(/^-/, "")); // login shells spell themselves "-zsh"
79
+ return SHELL_NAMES.has(name);
80
+ }
81
+
82
+ /** The pid the graceful end targets, from herdr's `pane process-info` result.process_info —
83
+ * parity with lib.rs foreground_pid_from_process_info. #6668: the 09-07 12:35 chain would have
84
+ * TERMed 80368, the pane's bare zsh, because foreground_process_group_id was taken as-is. The
85
+ * shell (process_info.shell_pid, or any entry named like one) is never a candidate: with only
86
+ * the shell in the foreground there is nothing to end, and the answer is null. */
87
+ export function foregroundPid(info) {
88
+ if (!info) return null;
89
+ const procs = Array.isArray(info.foreground_processes) ? info.foreground_processes : [];
90
+ const shellPid = Number(info.shell_pid) || 0;
91
+ const byPid = (pid) => procs.find(p => Number(p?.pid) === pid);
92
+ const usable = (pid) => pid > 0 && pid !== shellPid && !isShellProcess(byPid(pid));
93
+ const group = Number(info.foreground_process_group_id) || 0;
94
+ if (usable(group)) return group;
95
+ const claude = procs.find(p => /claude/i.test([p?.name, p?.argv0, p?.cmdline].map(s => String(s || "")).join(" ")) && !isShellProcess(p));
96
+ if (claude && Number(claude.pid) > 0 && Number(claude.pid) !== shellPid) return Number(claude.pid);
97
+ for (let i = procs.length - 1; i >= 0; i--) {
98
+ const pid = Number(procs[i]?.pid) || 0;
99
+ if (usable(pid)) return pid;
100
+ }
101
+ return null;
102
+ }
103
+
104
+ export function agentDropStep(present, elapsedMs, deadlineMs) {
105
+ if (!present) return "dropped";
106
+ return elapsedMs >= deadlineMs ? "deadline" : "wait";
107
+ }
108
+
109
+ function summary(value, limit = 240) {
110
+ const oneLine = String(value || "").trim().replace(/\s+/g, " ");
111
+ if (!oneLine) return "<empty>";
112
+ return oneLine.length > limit ? `${oneLine.slice(0, limit - 1)}…` : oneLine;
113
+ }
114
+
72
115
  async function main() {
73
116
  if (!handoffFile || !existsSync(handoffFile)) { log(`no handoff file (${handoffFile}) — abort`); process.exit(1); }
74
117
  const pane = arg("--pane") || orchPane((() => { try { return readFileSync(join(busDir, "crew-windows.txt"), "utf8"); } catch { return ""; } })(), projectName);
@@ -88,7 +131,7 @@ async function main() {
88
131
 
89
132
  // 2. Graceful end, mirroring end_process_gracefully: TERM, short wait, KILL.
90
133
  const info = herdrJson(["pane", "process-info", "--pane", pane])?.result?.process_info;
91
- const pid = Number(info?.foreground_process_group_id) || Number(info?.foreground_processes?.[0]?.pid) || 0;
134
+ const pid = foregroundPid(info) || 0;
92
135
  if (pid > 0 && alive(pid)) {
93
136
  try { process.kill(pid, "SIGTERM"); } catch {}
94
137
  const killAt = Date.now() + 10_000;
@@ -96,18 +139,26 @@ async function main() {
96
139
  if (alive(pid)) { try { process.kill(pid, "SIGKILL"); } catch {} }
97
140
  log(`ended pid ${pid}`);
98
141
  } else {
99
- log("no foreground process to end (already gone)");
142
+ log("no foreground process to end (already gone, or only the pane's shell is in the foreground)");
143
+ }
144
+
145
+ const dropStarted = Date.now();
146
+ const dropDeadline = Number(process.env.TRANTOR_BATON_AGENT_DROP_MS) || 10_000;
147
+ let dropOutcome;
148
+ while ((dropOutcome = agentDropStep(hasAgent(pane), Date.now() - dropStarted, dropDeadline)) === "wait") {
149
+ await sleep(200);
100
150
  }
151
+ log(`agent drop ${dropOutcome} after ${Date.now() - dropStarted}ms`);
101
152
 
102
153
  // 3. Reopen. `trantor open` rebinds orch-sessions.txt and restarts the pane's session — the
103
154
  // bookkeeping the classic seam regression comes from skipping. The drill overrides this to
104
155
  // stay inside its own herdr world.
105
156
  const reopen = process.env.TRANTOR_BATON_REOPEN || "trantor open";
106
- try {
107
- execSync(reopen, { cwd: projectDir, encoding: "utf8", timeout: 120_000, stdio: "pipe" });
108
- log(`reopened via: ${reopen}`);
109
- } catch (e) {
110
- log(`reopen FAILED (${String(e?.message).slice(0, 200)}) — handoff waits on disk`);
157
+ log(`reopen starting via: ${reopen}`);
158
+ const reopened = spawnSync(reopen, { cwd: projectDir, encoding: "utf8", timeout: 120_000, stdio: "pipe", shell: true });
159
+ log(`reopen result: status=${reopened.status ?? "none"} stdout=${summary(reopened.stdout)} stderr=${summary(reopened.stderr)}`);
160
+ if (reopened.error || reopened.status !== 0) {
161
+ log(`reopen FAILED (${summary(reopened.error?.message || reopened.signal || `exit ${reopened.status}`)}) — handoff waits on disk`);
111
162
  process.exit(1);
112
163
  }
113
164
 
@@ -118,7 +169,9 @@ async function main() {
118
169
  }
119
170
  try {
120
171
  const raw = await socketRequest({ id: "trantor:agent.prompt", method: "agent.prompt", params: { target: pane, text: KICKOFF_PROMPT } });
121
- log(`kickoff: ${String(JSON.parse(raw).result?.type)}`);
172
+ const response = JSON.parse(raw);
173
+ const result = response.result?.type || response.error?.code || "unexpected_response";
174
+ log(`kickoff result: ${result}${response.error?.message ? ` (${summary(response.error.message)})` : ""}`);
122
175
  } catch (e) {
123
176
  log(`kickoff FAILED (${String(e?.message).slice(0, 120)}) — successor may sit idle until spoken to`);
124
177
  }
package/bin/baton.mjs CHANGED
@@ -8,7 +8,7 @@ import { join, basename, dirname } from "node:path";
8
8
  import { homedir } from "node:os";
9
9
  import { spawn } from "node:child_process";
10
10
  import { fileURLToPath } from "node:url";
11
- import { writeHandoff, spawnBaton, resolveHandoffSurface, armBaton, contextUsage, controllingTty, turnInFlight, armMaxMs } from "../hooks/lib/handoff.mjs";
11
+ import { writeHandoff, spawnBaton, resolveHandoffSurface, armBaton, contextUsage, controllingTty, turnInFlight, armMaxMs, sessionProcessState } from "../hooks/lib/handoff.mjs";
12
12
 
13
13
  // #6074: the skill path (write-handoff.mjs) and this CLI path must share ONE resolution of which
14
14
  // project this is and where the session lives. Both call resolveHandoffSurface; the name comes
@@ -82,7 +82,13 @@ function autoBaton() {
82
82
  // a turn still in flight ARMS instead of writing: no record, no spawn, and the session's own
83
83
  // Stop hook fires the baton at the boundary, where the summary describes finished work.
84
84
  const force = process.argv.includes("--force");
85
- if (!force && turnInFlight(transcript)) {
85
+ // #6668: a transcript whose session has NO live process is at its boundary, whatever its last
86
+ // row says. The 09-07 12:35 chain armed on a session that had exited at 12:16 (its tail was a
87
+ // tool_result "Connection closed"), then sat in the 17-minute boundary wait for a Stop hook no
88
+ // process would ever run. Write now; the record describes a session that is over.
89
+ const processState = sessionProcessState(sessionId);
90
+ if (processState === "dead") console.log(`session ${sessionId} has no live process — at its boundary, writing now`);
91
+ if (!force && processState !== "dead" && turnInFlight(transcript)) {
86
92
  armBaton(sessionId, {
87
93
  projectDir: cwd,
88
94
  transcript, reason: trigger, windowId: "", tty: controllingTty(),
package/bin/connect.mjs CHANGED
@@ -16,6 +16,14 @@ import { fileURLToPath } from "node:url";
16
16
  const DRY = process.argv.includes("--dry-run");
17
17
  const MCP = join(dirname(dirname(fileURLToPath(import.meta.url))), "mcp.mjs");
18
18
  const URL_ = process.env.RELAY_URL || "http://127.0.0.1:4477";
19
+ // Graft (github.com/NanoNets/context-graph-engine): a local Tree-sitter dependency graph served
20
+ // over MCP (graft_find_code / _find_all / _trace_calls / _file_api / _repo_map). Wired next to
21
+ // `relay` so a seat can locate code with one call instead of grep+read-many — the graph refreshes
22
+ // itself before each query (no freshness hook) and serves the nearest ancestor with a graft/ index,
23
+ // so it keys off the seat's cwd project. A project with no graft/ index simply returns empty tools,
24
+ // never an error. `graft build` (or `graft init`) seeds a project's index; graft/ is gitignored.
25
+ const GRAFT = (() => { try { return execSync("command -v graft", { encoding: "utf8", shell: "/bin/sh" }).trim(); } catch { return "graft"; } })();
26
+ const HAS_GRAFT = GRAFT !== "graft" || (() => { try { execSync("command -v graft", { stdio: "ignore", shell: "/bin/sh" }); return true; } catch { return false; } })();
19
27
  const has = (cmd) => { try { execSync(`command -v ${cmd}`, { stdio: "ignore", shell: "/bin/sh" }); return true; } catch { return false; } };
20
28
  const stamp = new Date().toISOString().slice(0, 10);
21
29
  const backup = (p) => { const b = `${p}.bak-${stamp}`; if (!existsSync(b)) copyFileSync(p, b); return b; };
@@ -65,6 +73,16 @@ if (has("codex")) {
65
73
  if (!DRY) { if (existsSync(p)) backup(p); else mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, cur + block); }
66
74
  report("codex", cur ? "wired" : "wired (new config)", p);
67
75
  }
76
+ // graft alongside relay
77
+ if (HAS_GRAFT) {
78
+ const g = existsSync(p) ? readFileSync(p, "utf8") : "";
79
+ if (g.includes("[mcp_servers.graft]")) report("codex", "graft already wired");
80
+ else {
81
+ const gblock = `\n# trantor — Graft code-graph tools (graft_find_code/_find_all/_trace_calls/_file_api/_repo_map)\n[mcp_servers.graft]\ncommand = "${GRAFT}"\nargs = ["mcp"]\n`;
82
+ if (!DRY) { if (existsSync(p)) backup(p); writeFileSync(p, g + gblock); }
83
+ report("codex", "graft wired", p);
84
+ }
85
+ }
68
86
  }
69
87
 
70
88
  // ---- Gemini CLI ---- (existing relay entries are never overwritten — user customization wins)
@@ -73,6 +91,7 @@ if (has("gemini")) {
73
91
  report("gemini", patchJson(p, d => {
74
92
  d.mcpServers ||= {};
75
93
  d.mcpServers.relay ||= { command: "node", args: [MCP], env: relayEnv("gemini") };
94
+ if (HAS_GRAFT) d.mcpServers.graft ||= { command: GRAFT, args: ["mcp"] };
76
95
  }), p);
77
96
  }
78
97
 
@@ -82,6 +101,7 @@ if (has("kimi")) {
82
101
  report("kimi", patchJson(p, d => {
83
102
  d.mcpServers ||= {};
84
103
  d.mcpServers.relay ||= { command: "node", args: [MCP], env: relayEnv("kimi") };
104
+ if (HAS_GRAFT) d.mcpServers.graft ||= { command: GRAFT, args: ["mcp"] };
85
105
  }), p);
86
106
  }
87
107
 
@@ -92,6 +112,7 @@ if (has("opencode")) {
92
112
  d.$schema ||= "https://opencode.ai/config.json";
93
113
  d.mcp ||= {};
94
114
  d.mcp.relay ||= { type: "local", command: ["node", MCP], enabled: true };
115
+ if (HAS_GRAFT) d.mcp.graft ||= { type: "local", command: [GRAFT, "mcp"], enabled: true };
95
116
  d.mcp.relay.environment ||= {};
96
117
  // Migrate the old generated pin too: `||=` alone left RELAY_AGENT=opencode in every existing
97
118
  // config forever, where OpenCode overlaid it on the qwen/glm/deepseek runner environment.
@@ -135,7 +156,7 @@ if (has("dsh")) {
135
156
  },
136
157
  dsh: { profile: { bundles: ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-headless"] } },
137
158
  };
138
- const patch = `# trantor — generated by \`trantor connect\` (edits survive: regenerate by deleting this file)
159
+ const patch = `# trantor — generated by \`trantor connect\` (connect rewrites this file only when it is missing a row; delete it to force a full regen)
139
160
  - insert:
140
161
  - id: trantor-cc-hooks
141
162
  name: '@deepseek-ai/dsh-hooks-claude-code'
@@ -154,10 +175,29 @@ if (has("dsh")) {
154
175
  RELAY_AGENT: !!js process.env.RELAY_AGENT ?? 'dsh'
155
176
  RELAY_PROJECT: !!js process.env.RELAY_PROJECT ?? ''
156
177
  RELAY_SESSION: !!js process.env.RELAY_SESSION ?? ''
157
- `;
178
+ ${HAS_GRAFT ? ` - id: trantor-graft
179
+ name: '@deepseek-ai/dsh-mcp-client'
180
+ config:
181
+ serverName: graft
182
+ transport: stdio
183
+ command: ${GRAFT}
184
+ args: ['mcp']
185
+ ` : ""}`;
186
+ // "a profile exists" is not "a profile is current": connect grows rows over time (relay, then
187
+ // graft, then whatever comes next), and an existence check short-circuits on a profile written
188
+ // by an older connect forever — the seat silently never gets the new row. So the gate is
189
+ // CONTENT-based: every row id this connect would write must already be in the patch; a missing
190
+ // one regenerates the patch (backed up). Presence, not diff — user edits to rows that ARE there
191
+ // still win, the same rule as the gemini/kimi/opencode `||=` patches above.
192
+ const expectedIds = [...patch.matchAll(/- id: (\S+)/g)].map(m => m[1]);
193
+ const cur = existsSync(patchPath) ? readFileSync(patchPath, "utf8") : "";
194
+ const missing = expectedIds.filter(id => !cur.split("\n").some(l => l.trim() === `- id: ${id}`));
158
195
  const fresh = !existsSync(patchPath);
159
- if (!fresh) report("dsh", "already wired", prof);
160
- else {
196
+ if (!fresh && !missing.length) report("dsh", "already wired", prof);
197
+ else if (!fresh) {
198
+ if (!DRY) { backup(patchPath); writeFileSync(patchPath, patch); }
199
+ report("dsh", `regenerated — was missing: ${missing.join(", ")}`, prof);
200
+ } else {
161
201
  if (!DRY) {
162
202
  mkdirSync(prof, { recursive: true });
163
203
  // The seat runs the plugin's hooks MINUS SessionStart: the crew runner already owns
package/bin/crew/open.mjs CHANGED
@@ -1,8 +1,10 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { call } from "./core.mjs";
5
- import { createWorkspace, herdrCall, paneHasAgent, reportAgent, splitPane, workspaceList, workspacePane } from "./herdr.mjs";
2
+ import { existsSync, openSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { basename, join } from "node:path";
5
+ import { spawn, execSync } from "node:child_process";
6
+ import { call, parseJsonOutput } from "./core.mjs";
7
+ import { createWorkspace, herdrCall, reportAgent, splitPane, workspaceList, workspacePane } from "./herdr.mjs";
6
8
  import { dropState, readRows, recordState } from "./state.mjs";
7
9
  import { resolveOrchestratorDir } from "./worktrees.mjs";
8
10
 
@@ -72,6 +74,19 @@ function orchestratorCommand(ctx, id) {
72
74
  return `${env} claude${harnessFlag(ctx)} ${action}`;
73
75
  }
74
76
 
77
+ export function paneHasAgent(ctx, pane) {
78
+ const result = herdrCall(ctx, ["pane", "process-info", "--pane", pane]);
79
+ const info = parseJsonOutput(result.stdout)?.result?.process_info;
80
+ if (!result.ok || !info) return false;
81
+ const processes = Array.isArray(info.foreground_processes) ? info.foreground_processes : [];
82
+ return processes.some(process => {
83
+ const command = [process.name, process.argv0, process.cmdline, ...(Array.isArray(process.argv) ? process.argv : [])]
84
+ .filter(Boolean)
85
+ .join(" ");
86
+ return /(^|[/\s])claude(?:\.exe)?(?:$|\s)/i.test(command);
87
+ });
88
+ }
89
+
75
90
  function tracked(ctx, live) {
76
91
  let workspace = "";
77
92
  let pane = "";
@@ -91,7 +106,8 @@ function reattach(ctx, workspace, pane, id) {
91
106
  const renamed = herdrCall(ctx, ["pane", "rename", pane, `orchestrator · ${ctx.project}`]);
92
107
  if (!renamed.ok) { dropState(ctx, ctx.project, "orch"); return false; }
93
108
  if (!paneHasAgent(ctx, pane)) {
94
- herdrCall(ctx, ["pane", "run", pane, orchestratorCommand(ctx, id)]);
109
+ const started = herdrCall(ctx, ["pane", "run", pane, orchestratorCommand(ctx, id)]);
110
+ if (!started.ok) throw new Error(`trantor open: could not start the orchestrator in pane ${pane}`);
95
111
  reportAgent(ctx, pane, "claude");
96
112
  console.error(`— orchestrator pane was empty: resumed session ${id} in herdr:${workspace || "?"}/${pane} —`);
97
113
  } else console.error(`— orchestrator already hosted: reattached to herdr:${workspace || "?"}/${pane} —`);
@@ -136,6 +152,38 @@ function hostPane(ctx, chosen, id) {
136
152
  return pane;
137
153
  }
138
154
 
155
+ // #6888: index the project once so its seats' Graft tools (graft_find_code, _trace_calls, …) have a
156
+ // graph to serve. Non-blocking — the pane opens now, the ~7s build lands in the background; a no-op
157
+ // if graft isn't installed or the index already exists (the graph self-refreshes per query after the
158
+ // first build). Keeps the 20MB index out of the project's git.
159
+ export function maybeBuildGraft(dir) {
160
+ if (!dir) return;
161
+ try { execSync("command -v graft", { stdio: "ignore", shell: "/bin/sh" }); } catch { return; }
162
+ try {
163
+ if (existsSync(join(dir, "graft", ".graph"))) return;
164
+ const gi = join(dir, ".gitignore");
165
+ try {
166
+ const cur = existsSync(gi) ? readFileSync(gi, "utf8") : "";
167
+ if (!/^graft\/?$/m.test(cur)) writeFileSync(gi, cur + (cur && !cur.endsWith("\n") ? "\n" : "") + "graft/\n");
168
+ } catch {}
169
+ // The build is backgrounded, so we cannot gate the pane on its exit code — but a failure has to
170
+ // land SOMEWHERE. It used to go to stdio:"ignore", which meant a graft whose native parsers were
171
+ // never built (npm v12 skips node-gyp by default; see deploy/setup.sh) failed in total silence
172
+ // while the line below still claimed an index was being built.
173
+ //
174
+ // The log goes to the OS temp dir rather than the project: nothing to gitignore, nothing to clean
175
+ // up, and no exit handler — after unref() the parent usually dies first, so an on-exit cleanup
176
+ // would be a promise we cannot keep. It is simply overwritten by the next open of this project.
177
+ const log = join(tmpdir(), `trantor-graft-build-${basename(dir)}.log`);
178
+ let fd = "ignore";
179
+ try { fd = openSync(log, "w"); } catch {}
180
+ const child = spawn("graft", ["build", dir], { cwd: dir, stdio: ["ignore", fd, fd], detached: true });
181
+ child.on("error", () => {}); // ENOENT/EACCES: the log and the absent index are the evidence
182
+ child.unref();
183
+ console.error(`— indexing this project for the seats' Graft tools in the background (failures land in ${log}) —`);
184
+ } catch {}
185
+ }
186
+
139
187
  export function openOrchestrator(ctx, args) {
140
188
  let parsed;
141
189
  try { parsed = parseArgs(args); }
@@ -143,6 +191,7 @@ export function openOrchestrator(ctx, args) {
143
191
  if (parsed.help) { usage(); return 0; }
144
192
  try {
145
193
  Object.assign(ctx, resolveOrchestratorDir(ctx, parsed.project));
194
+ if (!ctx.dry) maybeBuildGraft(ctx.dir);
146
195
  if (!ctx.have.herdr) throw new Error("trantor open needs herdr (the pane host) — install: curl -fsSL https://herdr.dev/install.sh | sh");
147
196
  let id = sessionId(ctx);
148
197
  if (hasPendingHandoff(ctx)) {
@@ -28,7 +28,8 @@ import {
28
28
  senderProjectOf, isLinkedProject,
29
29
  } from "../lib/turn-policy.mjs";
30
30
  import {
31
- auditDutyNudges, claudeTranscriptDir, dutyNudgeDirective, observedDutyNudgeIds, planDutyNudges,
31
+ auditDutyNudges, claimDutyNudges, claudeTranscriptDir, dutyEscalations, dutyNudgeDirective,
32
+ observedDutyNudgeIds,
32
33
  } from "../lib/duty-nudges.mjs";
33
34
 
34
35
  const AGENT = process.argv[2];
@@ -1067,10 +1068,30 @@ function askedExcerpt(message) {
1067
1068
  // queued, on disk, with a backoff — which is the whole point of the change.
1068
1069
  async function deliverWake() {
1069
1070
  const wake = pendingWake;
1070
- const wakeCapped = capWake(wake);
1071
+ const dutyPlan = DUTY_NUDGES
1072
+ ? await claimDutyNudges({
1073
+ messages: wake,
1074
+ statePath: DUTY_NUDGE_STATE,
1075
+ owner: `${RUNNER_ID}:${TURN + 1}`,
1076
+ })
1077
+ : { items: [], targets: [], owner: "" };
1078
+ const claimedIds = new Set(dutyPlan.items.map(item => item.id));
1079
+ const wakeForTurn = DUTY_NUDGES
1080
+ ? wake.filter(message => {
1081
+ const escalation = dutyEscalations([message])[0];
1082
+ return !escalation || claimedIds.has(escalation.id);
1083
+ })
1084
+ : wake;
1085
+ if (!wakeForTurn.length) {
1086
+ pendingWake = [];
1087
+ savePending([], pendingBcast);
1088
+ log("duty escalation already nudged or reserved by another turn — consumed without a duplicate model wake");
1089
+ return;
1090
+ }
1091
+ const wakeCapped = capWake(wakeForTurn);
1071
1092
  const bcastCapped = capBcast(pendingBcast);
1072
1093
  const wakeText = wakeCapped.text
1073
- ? `NEW BUS MESSAGE${wake.length > 1 ? "S" : ""} for you:\n${wakeCapped.text}\n`
1094
+ ? `NEW BUS MESSAGE${wakeForTurn.length > 1 ? "S" : ""} for you:\n${wakeCapped.text}\n`
1074
1095
  : "";
1075
1096
  const ctxText = bcastCapped.text
1076
1097
  ? `\nFYI broadcasts since your last turn (context only):\n${bcastCapped.text}\n`
@@ -1078,29 +1099,26 @@ function askedExcerpt(message) {
1078
1099
  // Say plainly that this is a second look. Without it the model re-reads an old escalation as
1079
1100
  // brand new and can redo work it already half-did before the turn died.
1080
1101
  const againText = deliveryFails
1081
- ? `\n(REDELIVERY, attempt ${deliveryFails + 1} — an earlier turn failed before acting on ${wake.length > 1 ? "these" : "this"}. Check what you already did before repeating it.)\n`
1102
+ ? `\n(REDELIVERY, attempt ${deliveryFails + 1} — an earlier turn failed before acting on ${wakeForTurn.length > 1 ? "these" : "this"}. Check what you already did before repeating it.)\n`
1082
1103
  : "";
1083
1104
  await loadLessons();
1084
1105
  const lessons = pickLessons(LESSONS_RAW, wakeCapped.text + " " + bcastCapped.text);
1085
- const trigger = wake.some(m => m.to === SESSION) ? "direct message" : "@mention";
1106
+ const trigger = wakeForTurn.some(m => m.to === SESSION) ? "direct message" : "@mention";
1086
1107
  // Who is owed an answer, captured BEFORE the turn: pendingWake is cleared on success.
1087
1108
  const assigners = [];
1088
- for (const m of wake) if (m.from && !assigners.some(a => a.from === m.from)) assigners.push({ from: m.from, id: m.id });
1089
- const asked = askedExcerpt(wake[0]);
1109
+ for (const m of wakeForTurn) if (m.from && !assigners.some(a => a.from === m.from)) assigners.push({ from: m.from, id: m.id });
1110
+ const asked = askedExcerpt(wakeForTurn[0]);
1090
1111
  const tStart = Date.now();
1091
1112
  // #6134: ONE SESSION PER CARD. A seat that resumes forever carries every card it ever worked
1092
1113
  // into every later turn — qwen's 85.7M tokens were 96.7% cached, i.e. replayed history. The
1093
1114
  // card that moved this wake decides: a different one starts a fresh CLI session, and the seat
1094
1115
  // is told so, because a fresh session remembers nothing and must be sent to its card.
1095
- const card = wake.map(m => cardRef(m.text)).find(Boolean) || 0;
1116
+ const card = wakeForTurn.map(m => cardRef(m.text)).find(Boolean) || 0;
1096
1117
  const fresh = card > 0 && card !== sessionCard;
1097
1118
  if (card) sessionCard = card;
1098
1119
  const freshText = fresh
1099
1120
  ? `\n(FRESH SESSION for card #${card} — you are not the session that worked earlier cards and you remember none of them. Read your card first: relay_board with card:${card}.)\n`
1100
1121
  : "";
1101
- const dutyPlan = DUTY_NUDGES
1102
- ? planDutyNudges(wake, DUTY_NUDGE_STATE)
1103
- : { items: [], targets: [] };
1104
1122
  const prompt = composedTurn({
1105
1123
  wakeText, ctxText, againText: againText + freshText + dutyNudgeDirective(dutyPlan),
1106
1124
  tailText: "\nAct on what's addressed to you, then end your turn.\n\n",
@@ -0,0 +1,80 @@
1
+ import { mkdirSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { signedPost } from "../hooks/lib/api.mjs";
4
+
5
+ // A PASS assertion is evidence, not a verdict: every mapped step must finish cleanly.
6
+ export class DrillReport {
7
+ constructor(map, path, { project, session }) {
8
+ this.map = map;
9
+ this.path = path;
10
+ this.project = project;
11
+ this.session = session;
12
+ this.steps = {};
13
+ this.closures = {};
14
+ for (const { steps, autoClose, recipe } of Object.values(map)) {
15
+ if (autoClose || !recipe) continue;
16
+ for (const step of steps) this.steps[step] = {
17
+ complete: true, checks: [{ status: "skip", assertion: recipe, evidence: "" }],
18
+ };
19
+ }
20
+ this.write();
21
+ }
22
+
23
+ record(step, status, assertion, evidence = "") {
24
+ (this.steps[step] ||= { complete: false, checks: [] }).checks.push({ status, assertion, evidence });
25
+ this.write();
26
+ }
27
+
28
+ complete(step) {
29
+ (this.steps[step] ||= { complete: false, checks: [] }).complete = true;
30
+ this.write();
31
+ }
32
+
33
+ cardsFor(step) {
34
+ return Object.keys(this.map).filter(id => this.map[id].steps.includes(step));
35
+ }
36
+
37
+ results() {
38
+ return Object.fromEntries(Object.entries(this.map).map(([id, { steps }]) => {
39
+ const evidence = steps.flatMap(step => {
40
+ const row = this.steps[step];
41
+ return row?.checks.length
42
+ ? row.checks.map(check => `${step} ${check.status}: ${check.assertion}${check.evidence ? ` — ${check.evidence}` : ""}`)
43
+ : [`${step} fail: not run`];
44
+ });
45
+ const complete = steps.every(step => {
46
+ const row = this.steps[step];
47
+ return row?.complete && row.checks.length > 0 && row.checks.every(check => check.status !== "fail");
48
+ });
49
+ const skipped = steps.some(step => this.steps[step]?.checks.some(check => check.status === "skip"));
50
+ return [id, { status: complete ? skipped ? "skip" : "pass" : "fail", evidence, closure: this.closures[id] || "not attempted" }];
51
+ }));
52
+ }
53
+
54
+ write() {
55
+ mkdirSync(dirname(this.path), { recursive: true });
56
+ const temp = `${this.path}.${process.pid}.tmp`;
57
+ writeFileSync(temp, JSON.stringify(this.results(), null, 2) + "\n", { mode: 0o600 });
58
+ renameSync(temp, this.path);
59
+ }
60
+
61
+ exitCode() {
62
+ // Manual recipes stay visible as SKIP; only missing/failed proof or a refused close is red.
63
+ return Object.values(this.results()).some(result => result.status === "fail" || result.closure.startsWith("failed:")) ? 1 : 0;
64
+ }
65
+
66
+ async closePassed() {
67
+ for (const [id, result] of Object.entries(this.results())) {
68
+ if (!this.map[id].autoClose || result.status !== "pass" || this.closures[id]) continue;
69
+ const response = await signedPost("/task/update", {
70
+ id: Number(id), project: this.project, by: this.session, status: "done",
71
+ note: `trantor drill PASS\n${result.evidence.join("\n")}`.slice(0, 2000),
72
+ }, { project: this.project, session: this.session, timeoutMs: 15000 });
73
+ this.closures[id] = response.ok && response.json?.task?.status === "done"
74
+ && response.json.task.id === Number(id) && response.json.task.project === this.project
75
+ ? "done" : `failed: hub ${response.status} ${response.json?.error || "did not confirm done"}`;
76
+ this.write();
77
+ }
78
+ return Object.values(this.closures).every(value => value === "done");
79
+ }
80
+ }
@@ -0,0 +1,146 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { createServer, createConnection } from "node:net";
6
+ import { DrillReport } from "./drill-report.mjs";
7
+ import { CARD_STEPS, findHandoff } from "./drill-surface.mjs";
8
+ import { appVerdict, startDrillHub, stopChild, checkSocketHome } from "./drill-seams.mjs";
9
+ import { signedGet, signedPost } from "../hooks/lib/api.mjs";
10
+
11
+ test("S4 waits for the hook ledger when relay_handoff arrives first", () => {
12
+ const world = mkdtempSync(join(import.meta.dirname, "..", ".agent-bus-out", "ledger-"));
13
+ try {
14
+ assert.equal(findHandoff(join(world, "missing"), "trantor"), null);
15
+ writeFileSync(join(world, "trantor-100.json"), JSON.stringify({ summary: "tool handoff" }));
16
+ writeFileSync(join(world, "trantor-101.json"), "{");
17
+ writeFileSync(join(world, "trantor-102.json"), JSON.stringify({ states: [] }));
18
+ writeFileSync(join(world, "other-100.json"), JSON.stringify({ states: [{ state: "written" }] }));
19
+ assert.equal(findHandoff(world, "trantor"), null);
20
+ const ledger = join(world, "trantor-103.json");
21
+ writeFileSync(ledger, JSON.stringify({ states: [{ state: "written" }] }));
22
+ assert.equal(findHandoff(world, "trantor"), ledger);
23
+ writeFileSync(ledger, JSON.stringify({ states: [{ state: "written" }, { state: "claimed" }, { state: "recapped" }] }));
24
+ assert.equal(findHandoff(world, "trantor"), ledger);
25
+ } finally { rmSync(world, { recursive: true, force: true }); }
26
+ });
27
+
28
+ test("signed closer on an enforce hub: complete evidence closes, partial/failure/skip never does", async () => {
29
+ const out = join(import.meta.dirname, "..", ".agent-bus-out");
30
+ mkdirSync(out, { recursive: true });
31
+ const world = mkdtempSync(join(out, "closer-test-"));
32
+ const bus = join(world, "bus");
33
+ const hub = await startDrillHub(world, bus);
34
+ const saved = { ...process.env };
35
+ Object.assign(process.env, { RELAY_URL: hub.url, AGENT_BUS_DIR: bus, RELAY_SESSION: "drill:trantor", RELAY_PROJECT: "trantor" });
36
+ const project = "trantor", session = "drill:trantor";
37
+ const path = join(world, "drill-result.json");
38
+ try {
39
+ const create = await signedPost("/task", { project, by: session, title: "closer test", status: "testing" });
40
+ assert.equal(create.ok, true, JSON.stringify(create));
41
+ const id = create.json.task.id;
42
+ const report = new DrillReport({ [id]: { steps: ["one", "two"], autoClose: true } }, path, { project, session });
43
+ report.record("one", "pass", "first assertion", "real evidence A");
44
+ await report.closePassed();
45
+ assert.equal(report.results()[id].status, "fail");
46
+ assert.equal((await signedGet("/tasks?project=trantor")).json.tasks[0].status, "testing");
47
+ report.complete("one");
48
+ report.record("two", "pass", "second assertion", "real evidence B");
49
+ report.complete("two");
50
+ assert.equal(await report.closePassed(), true);
51
+ const card = (await signedGet("/tasks?project=trantor")).json.tasks[0];
52
+ assert.equal(card.status, "done");
53
+ assert.equal(card.workedBy, session);
54
+ assert.match(card.log.at(-1).text, /real evidence A/);
55
+ assert.match(card.log.at(-1).text, /real evidence B/);
56
+ assert.equal(JSON.parse(readFileSync(path, "utf8"))[id].closure, "done");
57
+ const notes = card.log.length;
58
+ await report.closePassed();
59
+ assert.equal((await signedGet("/tasks?project=trantor")).json.tasks[0].log.length, notes);
60
+ const unsigned = await fetch(`${hub.url}/task/update`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ id, status: "testing", by: session }) });
61
+ assert.equal(unsigned.status, 401);
62
+
63
+ for (const status of ["fail", "skip"]) {
64
+ const created = await signedPost("/task", { project, by: session, title: status, status: "testing" });
65
+ const blockedId = created.json.task.id;
66
+ const blocked = new DrillReport({ [blockedId]: { steps: ["step"], autoClose: true } }, path, { project, session });
67
+ blocked.record("step", "pass", "partial success");
68
+ blocked.record("step", status, "missing proof");
69
+ blocked.complete("step");
70
+ await blocked.closePassed();
71
+ assert.equal(blocked.results()[blockedId].status, status);
72
+ assert.equal((await signedGet("/tasks?project=trantor")).json.tasks.find(row => row.id === blockedId).status, "testing");
73
+ }
74
+ const missing = new DrillReport({ 999999: { steps: ["step"], autoClose: true } }, path, { project, session });
75
+ missing.record("step", "pass", "proof");
76
+ missing.complete("step");
77
+ assert.equal(await missing.closePassed(), false);
78
+ assert.match(missing.results()[999999].closure, /failed: hub 404/);
79
+ assert.equal(missing.exitCode(), 1);
80
+ const fresh = new DrillReport({ [id]: { steps: ["one"], autoClose: true } }, path, { project, session });
81
+ assert.equal(fresh.results()[id].status, "fail", "a rerun cannot inherit old PASS evidence");
82
+ } finally {
83
+ for (const key of Object.keys(process.env)) if (!(key in saved)) delete process.env[key];
84
+ Object.assign(process.env, saved);
85
+ await stopChild(hub.child);
86
+ rmSync(world, { recursive: true, force: true });
87
+ }
88
+ });
89
+
90
+ test("app proof requires every leg and rejects skipped key targets", () => {
91
+ assert.deepEqual(CARD_STEPS[6317].steps, ["S6-key-post", "S6-key-throw"]);
92
+ assert.equal(appVerdict("ask", "ask-drill PASS:"), false);
93
+ assert.equal(appVerdict("ask", "ask-drill open PASS\nask-drill cold PASS\nask-drill PASS:"), true);
94
+ const key = [1, 2, 3].map(pass => `key-drill pass=${pass} posted keyDown+keyUp`).join("\n") + "\nkey-drill verdict exit=0";
95
+ assert.equal(appVerdict("key-post", key), true);
96
+ assert.equal(appVerdict("key-post", key + "\npass=2 skipped"), false);
97
+ assert.equal(appVerdict("key-throw", key), false);
98
+ assert.equal(appVerdict("key-throw", key, "TaoObjcExceptionDrill"), true);
99
+ assert.equal(appVerdict("handoff", "handoff-drill PASS:\nhandoff-drill verdict exit=3"), false);
100
+ });
101
+
102
+ test("manual probes remain SKIP even when the runner stops before reaching them", () => {
103
+ const world = mkdtempSync(join(import.meta.dirname, "..", ".agent-bus-out", "skip-"));
104
+ try {
105
+ const report = new DrillReport(CARD_STEPS, join(world, "result.json"), { project: "trantor", session: "drill:trantor" });
106
+ assert.deepEqual(Object.keys(CARD_STEPS).filter(id => CARD_STEPS[id].autoClose), ["6481", "6667", "6668"]);
107
+ for (const id of [6317, 6533, 6587, 6483]) {
108
+ assert.equal(report.results()[id].status, "skip");
109
+ assert.equal(report.results()[id].closure, "not attempted");
110
+ assert.match(report.results()[id].evidence.join(" "), id === 6587 ? /live duty probe/ : /covered by in-app Drill Mode/);
111
+ }
112
+ assert.equal(report.exitCode(), 1, "unrun seams still fail");
113
+ for (const { steps, recipe } of Object.values(CARD_STEPS)) {
114
+ if (recipe) continue;
115
+ for (const step of steps) {
116
+ report.record(step, step === "S5" ? "skip" : "pass", step === "S5" ? "interactive Terminal takeover probe" : "seam proof");
117
+ report.complete(step);
118
+ }
119
+ }
120
+ assert.equal(report.exitCode(), 0, "passing seams plus manual SKIPs exit zero");
121
+ report.record("S4", "fail", "ledger missing");
122
+ assert.equal(report.exitCode(), 1, "a real seam failure remains fatal");
123
+ } finally { rmSync(world, { recursive: true, force: true }); }
124
+ });
125
+
126
+ test("short drill HOME connects through the native socket symlink; old staging is rejected", async () => {
127
+ const world = mkdtempSync(`${join(import.meta.dirname, "..", ".agent-bus-out")}/`);
128
+ const server = createServer(socket => socket.end("connected"));
129
+ try {
130
+ const path = checkSocketHome(world);
131
+ assert.throws(() => checkSocketHome(join(world, "x".repeat(108))), /too long/);
132
+ mkdirSync(join(world, ".config", "herdr"), { recursive: true });
133
+ const target = join(world, "s");
134
+ await new Promise((resolve, reject) => { server.once("error", reject); server.listen(target, resolve); });
135
+ symlinkSync(target, path);
136
+ const proof = await new Promise((resolve, reject) => {
137
+ const socket = createConnection(path);
138
+ socket.once("error", reject);
139
+ socket.once("data", data => resolve(data.toString()));
140
+ });
141
+ assert.equal(proof, "connected");
142
+ } finally {
143
+ await new Promise(resolve => server.close(resolve));
144
+ rmSync(world, { recursive: true, force: true });
145
+ }
146
+ });