trantor 0.18.49 → 0.18.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ });
@@ -0,0 +1,157 @@
1
+ import { execFileSync, spawn } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, symlinkSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { createServer } from "node:net";
6
+ import { workspacePane, splitPane } from "./crew/herdr.mjs";
7
+
8
+ const ROOT = dirname(import.meta.dirname);
9
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
10
+ const herdr = args => JSON.parse(execFileSync("herdr", args, { encoding: "utf8", timeout: 30000 }));
11
+ const read = path => existsSync(path) ? readFileSync(path, "utf8") : "";
12
+
13
+ export function checkSocketHome(home) {
14
+ const socket = join(home, ".config", "herdr", "herdr.sock");
15
+ if (Buffer.byteLength(socket) >= (process.platform === "darwin" ? 104 : 108)) {
16
+ throw new Error(`drill HOME socket path is too long: ${socket}; use a shorter TRANTOR_DRILL_WORLD inside .agent-bus-out`);
17
+ }
18
+ return socket;
19
+ }
20
+
21
+ export function isolatedEnv(home, bus, overrides = {}) {
22
+ const env = { ...process.env };
23
+ for (const key of Object.keys(env)) {
24
+ if (/^(RELAY_|TRANTOR_|CLAUDE_CODE_|HERDR_|AGENT_BUS_)/.test(key) || key === "CLAUDECODE") delete env[key];
25
+ }
26
+ return { ...env, HOME: home, AGENT_BUS_DIR: bus, RELAY_DATA_DIR: bus, ...overrides };
27
+ }
28
+
29
+ export async function stopChild(child) {
30
+ if (child.exitCode !== null || child.signalCode !== null) return;
31
+ child.kill("SIGTERM");
32
+ await Promise.race([new Promise(resolve => child.once("close", resolve)), sleep(2000)]);
33
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
34
+ }
35
+
36
+ export async function startDrillHub(home, bus, auth = "enforce") {
37
+ mkdirSync(bus, { recursive: true });
38
+ const probe = createServer();
39
+ await new Promise(resolve => probe.listen(0, "127.0.0.1", resolve));
40
+ const port = probe.address().port;
41
+ await new Promise(resolve => probe.close(resolve));
42
+ const url = `http://127.0.0.1:${port}`;
43
+ const env = isolatedEnv(home, bus, {
44
+ RELAY_PORT: String(port), RELAY_HOST: "127.0.0.1", RELAY_URL: url,
45
+ RELAY_AUTH: auth, RELAY_ENROLL: "tofu", RELAY_STORE: "json",
46
+ });
47
+ const child = spawn(process.execPath, [join(ROOT, "hub.mjs")], { env, stdio: "ignore" });
48
+ for (let attempt = 0; attempt < 100; attempt++) {
49
+ try {
50
+ if ((await fetch(`${url}/health`, { signal: AbortSignal.timeout(500) })).ok) return { child, env, url };
51
+ } catch { /* The private hub has not bound its port yet. */ }
52
+ if (child.exitCode !== null) break;
53
+ await sleep(100);
54
+ }
55
+ await stopChild(child);
56
+ throw new Error("private drill hub did not become ready");
57
+ }
58
+
59
+ export function appVerdict(kind, trace, panics = "") {
60
+ if (/\b(?:FAILED|FAIL|ERROR)\b/.test(trace)) return false;
61
+ if (kind === "ask") return /ask-drill open PASS/.test(trace) && /ask-drill cold PASS/.test(trace) && /ask-drill PASS:/.test(trace);
62
+ if (kind === "handoff") return /handoff-drill PASS:/.test(trace) && /handoff-drill verdict exit=0/.test(trace);
63
+ return /key-drill verdict exit=0/.test(trace) && !/skipped/.test(trace)
64
+ && [1, 2, 3].every(pass => trace.includes(`pass=${pass} posted keyDown+keyUp`))
65
+ && (kind !== "key-throw" || panics.includes("TaoObjcExceptionDrill"));
66
+ }
67
+
68
+ async function launchApp(kind, env, bus) {
69
+ const binary = process.env.TRANTOR_DRILL_APP || "/Applications/Trantor.app/Contents/MacOS/Trantor";
70
+ if (!existsSync(binary)) throw new Error(`installed app missing: ${binary}; no build/install attempted`);
71
+ const tracePath = join(bus, "app-trace.log");
72
+ const panicPath = join(bus, "app-panics.log");
73
+ const offset = read(tracePath).length;
74
+ const panicOffset = read(panicPath).length;
75
+ const child = spawn(binary, [], { env, stdio: "ignore", timeout: 180000, killSignal: "SIGKILL" });
76
+ let spawnError = null;
77
+ child.once("error", error => { spawnError = error; });
78
+ const deadline = Date.now() + 180000;
79
+ let trace = "";
80
+ try {
81
+ await sleep(1000);
82
+ execFileSync("osascript", ["-e", `tell application "System Events" to set frontmost of (first process whose unix id is ${child.pid}) to true`], { timeout: 10000, stdio: "ignore" });
83
+ while (Date.now() < deadline) {
84
+ trace = read(tracePath).slice(offset);
85
+ if (spawnError) throw spawnError;
86
+ if (appVerdict(kind, trace, read(panicPath).slice(panicOffset))) return `${tracePath}: ${trace.trim().split("\n").filter(line => /PASS|survived|verdict/.test(line)).join("; ")}`;
87
+ if (/\b(?:FAILED|FAIL|ERROR)\b/.test(trace) || child.exitCode !== null || child.signalCode !== null) break;
88
+ await sleep(500);
89
+ }
90
+ throw new Error(`${kind}: no complete app proof (exit=${child.exitCode} signal=${child.signalCode}); ${tracePath}: ${trace.trim().slice(-500)}`);
91
+ } finally {
92
+ await stopChild(child);
93
+ // The app normally closes these in its own finally. If it timed out first, only this
94
+ // launch's trace can authorize cleanup; never sweep the shared herdr workspace list.
95
+ for (const match of trace.matchAll(/ask-drill (?:open|cold) herdr workspace=(\S+)/g)) {
96
+ try { herdr(["workspace", "close", match[1]]); } catch { /* Already closed by the app. */ }
97
+ }
98
+ }
99
+ }
100
+
101
+ export async function runAppDrills({ world, proj, project, run }) {
102
+ // The HOME socket pathname must fit sockaddr_un even when it is a symlink.
103
+ const home = world;
104
+ const bus = join(home, "app-bus");
105
+ mkdirSync(join(home, ".config", "herdr"), { recursive: true });
106
+ const socket = checkSocketHome(home);
107
+ if (!existsSync(socket)) symlinkSync(join(homedir(), ".config", "herdr", "herdr.sock"), socket);
108
+ let hub;
109
+ try {
110
+ hub = await startDrillHub(home, bus, "off");
111
+ await fetch(`${hub.url}/project`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ project, brief: "Throwaway drill project" }) });
112
+ writeFileSync(join(bus, "config.json"), JSON.stringify({ url: hub.url, hubs: { [project]: hub.url }, contextWindow: 200000 }));
113
+ const env = { ...hub.env, TRANTOR_DEV_ROOT: dirname(proj), TRANTOR_ROOT: ROOT };
114
+ const created = herdr(["workspace", "create", "--cwd", proj, "--label", `tt-dead-drill-${process.pid}`, "--no-focus"]);
115
+ const workspace = created.result.workspace.workspace_id;
116
+ const pane = created.result.root_pane.pane_id;
117
+ try {
118
+ const sid = "00000000-0000-4000-8000-000000000092";
119
+ const transcripts = join(home, ".claude", "projects", proj.replace(/[/.]/g, "-"));
120
+ mkdirSync(transcripts, { recursive: true });
121
+ writeFileSync(join(transcripts, `${sid}.jsonl`), JSON.stringify({ type: "assistant", sessionId: sid, cwd: proj, timestamp: new Date().toISOString(), message: { model: "claude-sonnet-4-5", role: "assistant", usage: { input_tokens: 184000, output_tokens: 1, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, content: [{ type: "text", text: "dead drill session" }] } }) + "\n");
122
+ writeFileSync(join(bus, "crew-windows.txt"), `${project}\therdrws\t__ws__\t${workspace}\n${project}\torch\torchestrator\t${pane}\n`);
123
+ writeFileSync(join(bus, "orch-sessions.txt"), `${project}\t${sid}\n`);
124
+ await run("S6-handoff · app dead-pane guard", async () => {
125
+ const before = herdr(["pane", "process-info", "--pane", pane]).result.process_info.shell_pid;
126
+ const evidence = await launchApp("handoff", { ...env, TRANTOR_HANDOFF_DRILL: project }, bus);
127
+ const after = herdr(["pane", "process-info", "--pane", pane]).result.process_info.shell_pid;
128
+ if (!before || before !== after) throw new Error("dead-pane shell did not survive");
129
+ return `${evidence}; shell ${after} survived`;
130
+ });
131
+ } finally { herdr(["workspace", "close", workspace]); }
132
+ } catch (error) {
133
+ await run("S6-handoff · app setup", () => { throw error; });
134
+ } finally { if (hub) await stopChild(hub.child); }
135
+ }
136
+
137
+ export async function crewWorkspaceDrill({ proj, workspace }) {
138
+ if (!workspace) throw new Error("S1 left no project workspace");
139
+ const ctx = { env: process.env, have: { herdr: true } };
140
+ const host = workspacePane(ctx, workspace, proj);
141
+ if (!host) throw new Error("no project-local host pane");
142
+ const foreign = herdr(["workspace", "create", "--cwd", proj, "--label", `tt-focus-drill-${process.pid}`, "--focus"]);
143
+ const panes = [];
144
+ try {
145
+ for (let i = 0; i < 4; i++) {
146
+ const pane = splitPane(ctx, panes.at(-1) || host, "right", proj);
147
+ if (!pane) throw new Error("crew split failed");
148
+ panes.push(pane);
149
+ }
150
+ const rows = herdr(["pane", "list"]).result.panes;
151
+ if (!panes.every(id => rows.some(row => row.pane_id === id && row.workspace_id === workspace && row.cwd === proj))) throw new Error("a crew split landed outside the project workspace/cwd");
152
+ return `focused=${foreign.result.workspace.workspace_id}; seats=${panes.join(",")}; workspace=${workspace}; cwd=${proj}`;
153
+ } finally {
154
+ for (const pane of panes) herdr(["pane", "close", pane]);
155
+ herdr(["workspace", "close", foreign.result.workspace.workspace_id]);
156
+ }
157
+ }