worktrust 0.6.6 → 0.6.9

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/log-session.mjs CHANGED
@@ -33,6 +33,9 @@
33
33
  * CODEX ROLLOUTS (2026-09-27) are swept beside the Claude Code transcripts: ~/.codex/sessions holds
34
34
  * the same clocks, the model per turn and a token record per response, and the reader below turns
35
35
  * them into the lines this script already measures. One hook, two clients, one rule for both.
36
+ * ANTIGRAVITY (2026-10-04) the same way: its brain/<id>/.system_generated/logs/transcript.jsonl is swept
37
+ * as a third client (clocks and turns; it records no tokens), woken by its own Stop hook through
38
+ * `worktrust.mjs hook --antigravity-stop`. The readers live in transcript-readers.mjs beside this file.
36
39
  *
37
40
  * WHAT CANNOT LEAVE. The payload is built from a fixed allowlist of keys, all of them numbers,
38
41
  * dates or vocabulary from the door's own enums. No prompt, no answer, no file path, no commit
@@ -51,9 +54,10 @@
51
54
  * honoured here too, matched against the transcript's project directory, before a file is read.
52
55
  */
53
56
  import { execFileSync, spawn } from "node:child_process";
54
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
57
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
55
58
  import { homedir } from "node:os";
56
59
  import { dirname, join } from "node:path";
60
+ import { fileURLToPath } from "node:url";
57
61
  import { createHash, randomBytes, sign as cryptoSign } from "node:crypto";
58
62
 
59
63
  const args = process.argv.slice(2);
@@ -97,6 +101,7 @@ async function refreshCounter(origin, announce) {
97
101
  const have = versionOf(readFileSync(COUNTER, "utf8")), theirs = versionOf(remote);
98
102
  if (!newer(theirs, have)) return false;
99
103
  writeFileSync(COUNTER, remote);
104
+ await installReaders(origin, true);
100
105
  announce?.(`counter: updated to counter@${theirs.join(".")} (was counter@${have?.join(".") ?? "?"}) — the next reading counts under the new rubric`);
101
106
  return true;
102
107
  } catch { return false; }
@@ -139,6 +144,15 @@ const HOOK_EVENTS = ["SessionEnd", "SessionStart", "PreCompact"];
139
144
  const STATE = join(homedir(), ".worktrust", "sessions.json");
140
145
  const readState = () => { try { return JSON.parse(readFileSync(STATE, "utf8")); } catch { return {}; } };
141
146
  const writeState = (state) => { mkdirSync(dirname(STATE), { recursive: true }); writeFileSync(STATE, JSON.stringify(state, null, 1)); };
147
+ /**
148
+ * THE FLOORS (owner, 2026-10-04: "history is never sent silently"). `__from` is the moment this
149
+ * computer was coupled: the hook sends nothing measured before it, so a fresh coupling (or one after
150
+ * `disconnect` purged the watermarks) never sweeps the computer's past into the record unasked. That
151
+ * past is offered once, by `npx worktrust`, and goes only on a yes. `__historyFloor` is set when the
152
+ * computer changed ACCOUNT: even that offer then starts at the change, so one person's earlier work
153
+ * is never offered to the next account.
154
+ */
155
+ const floorOf = (state, key) => { const at = typeof state?.[key] === "string" ? Date.parse(state[key]) : NaN; return Number.isFinite(at) ? at : null; };
142
156
 
143
157
  /**
144
158
  * ONLY THE HOUSE'S OWN DOOR (2026-09-27, audit): the coupling used to be "any MCP server whose URL
@@ -181,119 +195,53 @@ const excludes = (() => {
181
195
  try { return (JSON.parse(readFileSync(join(homedir(), ".worktrust-counter.json"), "utf8")).exclude ?? []).map(String); } catch { return []; }
182
196
  })();
183
197
  const excluded = (path) => excludes.some((text) => text && path.includes(text));
184
- // ── codex rollout reader (the same text in count-behaviour.mjs and log-session.mjs; test-codex-rollout holds them equal) ──
185
198
  /**
186
- * A CODEX ROLLOUT, READ AS A TRANSCRIPT (2026-09-27). Codex keeps one JSONL per thread under
187
- * ~/.codex/sessions/YYYY/MM/DD/rollout-<stamp>-<uuid>.jsonl: a session_meta line, then per turn a
188
- * turn_context (model, effort, cwd), response_items (messages, tool calls, their outputs) and a
189
- * token_usage_record per model response. On the machine this was built on five of them held 679
190
- * million tokens that nothing read. This turns the records into the lines Claude Code writes, so
191
- * ONE rubric and ONE clock read both:
192
- * · a user or assistant message → a user or assistant line with a text part; the developer line
193
- * and the harness's own user-role messages (<environment_context>, <recommended_plugins> and
194
- * the like: a tag opening the text) are the harness, not the person, and are dropped;
195
- * · a tool call → an assistant line with a tool_use part: `exec` becomes Bash with the command
196
- * as input, `apply_patch` an Edit with the first path it names, `spawn_agent` an Agent; its
197
- * output → a user line with a tool_result, is_error read from Codex's own "exit code N";
198
- * · a token_usage_record → an assistant line carrying the usage in Claude's keys, ONCE per
199
- * response_id (Codex writes a response more than once): input minus cached is new input,
200
- * cached is a cache read, cache_write a cache write, output already holds the reasoning tokens.
201
- * The running total in event_msg/token_count is never read: it is a counter, not a record.
202
- * Text is read here for the rubric and the layer and travels nowhere, as with every transcript.
199
+ * THE READERS FOR CODEX AND ANTIGRAVITY live in transcript-readers.mjs beside this file (2026-10-04;
200
+ * the counter imports the same file, so there is one reader, not two copies). The install copies it
201
+ * into ~/.worktrust with this hook. Without it this hook still reads Claude Code; the never-matching
202
+ * patterns below keep every other client out rather than half read.
203
203
  */
204
- const CODEX_ROLLOUT = /(^|\/)rollout-\d{4}-\d{2}-\d{2}T[\d-]+-[0-9a-f-]{36}\.jsonl$/;
205
- const CODEX_HARNESS_TURN = /^\s*<[a-z][a-z_]*[\s>]/i;
206
- const codexToolName = (name) => (name === "exec" || name === "shell" || name === "container.exec" || name === "local_shell" ? "Bash" : name === "apply_patch" ? "Edit" : name === "spawn_agent" ? "Agent" : String(name ?? "tool"));
207
- const codexToolInput = (name, raw) => {
208
- const mapped = codexToolName(name);
209
- if (mapped === "Bash") {
210
- if (typeof raw === "string") { try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === "object") return { command: Array.isArray(parsed.command) ? parsed.command.join(" ") : String(parsed.command ?? parsed.cmd ?? raw) }; } catch { /* the string is the command */ } return { command: raw }; }
211
- return { command: Array.isArray(raw?.command) ? raw.command.join(" ") : String(raw?.command ?? raw?.cmd ?? "") };
212
- }
213
- if (mapped === "Edit") { const path = /\*\*\* (?:Update|Add|Delete) File: ([^\n]+)/.exec(typeof raw === "string" ? raw : JSON.stringify(raw ?? "")); return { file_path: path ? path[1].trim() : "" }; }
214
- return {};
215
- };
216
- const codexOutputText = (output) => (typeof output === "string" ? output : Array.isArray(output) ? output.map((part) => (typeof part === "string" ? part : part?.text ?? "")).join("\n") : "");
217
- /** The rollout's raw JSON lines → transcript-shaped line objects, in file order. */
218
- function* codexLines(records) {
219
- let cwd = null, model = null;
220
- const seenResponses = new Set();
221
- for (const raw of records) {
222
- if (!raw || !String(raw).trim()) continue;
223
- let record; try { record = JSON.parse(raw); } catch { continue; }
224
- const timestamp = typeof record?.timestamp === "string" ? record.timestamp : null;
225
- const payload = record?.payload && typeof record.payload === "object" ? record.payload : {};
226
- if (record.type === "session_meta") { if (typeof payload.cwd === "string") cwd = payload.cwd; continue; }
227
- if (record.type === "turn_context") { if (typeof payload.cwd === "string") cwd = payload.cwd; if (typeof payload.model === "string") model = payload.model; continue; }
228
- if (record.type === "token_usage_record") {
229
- const id = String(payload.response_id ?? "");
230
- const usage = payload.usage;
231
- if (!usage || typeof usage !== "object" || (id && seenResponses.has(id))) continue;
232
- if (id) seenResponses.add(id);
233
- const n = (key) => (typeof usage[key] === "number" && Number.isFinite(usage[key]) ? usage[key] : 0);
234
- yield { type: "assistant", timestamp, cwd, uuid: id ? `codex-usage-${id}` : undefined, message: { ...(model ? { model } : {}), content: [], usage: { input_tokens: Math.max(0, n("input_tokens") - n("cached_input_tokens")), output_tokens: n("output_tokens"), cache_read_input_tokens: n("cached_input_tokens"), cache_creation_input_tokens: n("cache_write_input_tokens") } } };
235
- continue;
236
- }
237
- if (record.type !== "response_item") continue;
238
- const kind = payload.type;
239
- if (kind === "message") {
240
- if (payload.role !== "user" && payload.role !== "assistant") continue;
241
- const text = Array.isArray(payload.content) ? payload.content.filter((part) => typeof part?.text === "string").map((part) => part.text).join("\n") : typeof payload.content === "string" ? payload.content : "";
242
- if (payload.role === "user" && CODEX_HARNESS_TURN.test(text)) continue;
243
- yield { type: payload.role, timestamp, cwd, uuid: payload.id ? `codex-${payload.id}` : undefined, message: { ...(payload.role === "assistant" && model ? { model } : {}), content: [{ type: "text", text }] } };
244
- continue;
245
- }
246
- if (kind === "custom_tool_call" || kind === "function_call") {
247
- const input = kind === "function_call" ? (() => { try { return JSON.parse(payload.arguments ?? "{}"); } catch { return {}; } })() : payload.input;
248
- yield { type: "assistant", timestamp, cwd, uuid: payload.id ? `codex-${payload.id}` : undefined, message: { ...(model ? { model } : {}), content: [{ type: "tool_use", ...(payload.call_id ? { id: String(payload.call_id) } : {}), name: codexToolName(payload.name), input: codexToolInput(payload.name, input) }] } };
249
- continue;
250
- }
251
- if (kind === "custom_tool_call_output" || kind === "function_call_output") {
252
- const text = codexOutputText(payload.output);
253
- const exit = /(?:failed with|exited with|exit code)[:\s]+(-?\d+)/i.exec(text.slice(-400));
254
- yield { type: "user", timestamp, cwd, uuid: payload.id ? `codex-${payload.id}` : undefined, message: { content: [{ type: "tool_result", ...(payload.call_id ? { tool_use_id: String(payload.call_id) } : {}), content: text, is_error: exit ? exit[1] !== "0" : false }] } };
255
- }
256
- }
257
- }
258
- /** Every rollout under a Codex sessions root, in a stable order. */
259
- function* codexRolloutFiles(root) {
260
- let entries = []; try { entries = readdirSync(root, { withFileTypes: true }); } catch { return; }
261
- for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
262
- const full = join(root, entry.name);
263
- if (entry.isDirectory()) yield* codexRolloutFiles(full);
264
- else if (CODEX_ROLLOUT.test(entry.name)) yield full;
265
- }
266
- }
267
- /** The rollout's working directory, from its first line alone: the file is not read for it. */
268
- function codexCwd(file) {
269
- let fd; try { fd = openSync(file, "r"); } catch { return null; }
204
+ const { CODEX_ROLLOUT = /(?!)/, codexLines, codexRolloutFiles = function* () {}, codexCwd = () => null, ANTIGRAVITY_TRANSCRIPT = /(?!)/, antigravityLines, antigravityRoots = () => [], antigravityTranscripts = function* () {}, antigravityContext = () => ({}), rememberAntigravity = () => null } = (await import("./transcript-readers.mjs").catch(() => null)) ?? {};
205
+ const READERS = join(dirname(fileURLToPath(import.meta.url)), "transcript-readers.mjs");
206
+ const READERS_HOME = join(homedir(), ".worktrust", "transcript-readers.mjs");
207
+ /** The readers go where the hook and the counter run: copied from beside this file, else (or for a newer counter) from the deployment. */
208
+ async function installReaders(origin, fromNetwork = false) {
270
209
  try {
271
- const buffer = Buffer.allocUnsafe(64 * 1024);
272
- const read = readSync(fd, buffer, 0, buffer.length, 0);
273
- const first = buffer.toString("utf8", 0, read).split("\n")[0] ?? "";
274
- const meta = JSON.parse(first);
275
- return meta?.type === "session_meta" && typeof meta.payload?.cwd === "string" ? meta.payload.cwd : null;
276
- } catch { return null; } finally { closeSync(fd); }
210
+ if (!fromNetwork && existsSync(READERS)) { mkdirSync(dirname(READERS_HOME), { recursive: true }); if (READERS !== READERS_HOME) copyFileSync(READERS, READERS_HOME); return true; }
211
+ if (!origin) return false;
212
+ const response = await fetch(`${origin}/counter/transcript-readers.mjs`);
213
+ if (!response.ok) return false;
214
+ writeFileSync(READERS_HOME, await response.text());
215
+ return true;
216
+ } catch { return false; }
277
217
  }
278
- // ── end codex rollout reader ──
279
218
 
280
219
  /** The roots this machine's transcripts live under; a test names its own. */
281
220
  const claudeRoot = () => value("transcript-root") ?? join(homedir(), ".claude", "projects");
282
221
  const codexRoot = () => { const given = value("codex-root"); if (given) return given.replace(/^~(?=\/|$)/, homedir()); return value("transcript-root") ? null : join(homedir(), ".codex", "sessions"); };
283
- /** Every Codex rollout on this machine, an excluded project's threads skipped by their working directory. */
222
+ /** Every Codex rollout and Antigravity conversation on this machine, an excluded project's threads skipped by their working directory. */
284
223
  function* codexRollouts() {
285
224
  const root = codexRoot();
286
- if (!root) return;
287
- for (const file of codexRolloutFiles(root)) {
225
+ for (const file of root ? codexRolloutFiles(root) : []) {
288
226
  const cwd = codexCwd(file);
289
227
  if (cwd && excluded(cwd)) continue;
290
228
  yield { id: `codex:${file.split("/").at(-1).replace(/\.jsonl$/, "")}`, file };
291
229
  }
230
+ yield* antigravityConversations();
231
+ }
232
+ /** Antigravity beside Codex: one transcript per conversation, its folder known only from the Stop hook. */
233
+ function* antigravityConversations() {
234
+ const given = value("antigravity-root");
235
+ for (const { conversationId, file } of antigravityTranscripts(given || !value("transcript-root") ? antigravityRoots(given) : [])) {
236
+ const { cwd } = antigravityContext(conversationId);
237
+ if (!(cwd && excluded(cwd))) yield { id: `antigravity:${conversationId}`, file };
238
+ }
292
239
  }
293
240
  /** A transcript's lines as objects, whichever client wrote it; null when the file cannot be read. */
294
241
  const parsedLines = (file) => {
295
242
  let lines; try { lines = readFileSync(file, "utf8").split("\n"); } catch { return null; }
296
243
  if (CODEX_ROLLOUT.test(file)) return [...codexLines(lines)];
244
+ if (ANTIGRAVITY_TRANSCRIPT.test(file)) { const id = file.split("/").at(-4); return [...antigravityLines(lines, { id, ...antigravityContext(id) })]; }
297
245
  const out = [];
298
246
  for (const raw of lines) { if (!raw.trim()) continue; try { out.push(JSON.parse(raw)); } catch { /* a torn line */ } }
299
247
  return out;
@@ -503,15 +451,16 @@ function payloadFor(stretch) {
503
451
  async function sendHistory(door, entries, announce = false) {
504
452
  let accepted = 0;
505
453
  // A Codex entry goes through Codex's own coupling where one exists (see codexDoor), the rest
506
- // through the door given: the record files a line under the coupling that sent it.
507
- for (const [source, group] of [["claude", entries.filter((entry) => entrySource.get(entry) !== "codex")], ["codex", entries.filter((entry) => entrySource.get(entry) === "codex")]]) {
454
+ // through the door given: the record files a line under the coupling that sent it. Antigravity's
455
+ // go apart too, so their batch can name the client (clientOf).
456
+ for (const [source, group] of [["claude", entries.filter((entry) => !entrySource.has(entry))], ["codex", entries.filter((entry) => entrySource.get(entry) === "codex")], ["antigravity", entries.filter((entry) => entrySource.get(entry) === "antigravity")]]) {
508
457
  const target = source === "codex" ? codexDoor ?? door : door;
509
458
  if (group.length === 0 || !target) continue;
510
459
  for (let i = 0; i < group.length; i += 200) {
511
460
  const batch = group.slice(i, i + 200);
512
461
  // History carries no shas: an imported line forms no session, and the join is the live route's.
513
462
  const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "import_history", arguments: { entries: batch.map((entry) => { const { commits, ...rest } = entry; void commits; return rest; }) } } });
514
- const response = await fetch(target.url, { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${target.token}`, ...proofFor(target.url, body) }, body });
463
+ const response = await fetch(target.url, { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${target.token}`, ...clientOf(source), ...proofFor(target.url, body) }, body });
515
464
  const text = await response.text();
516
465
  if (announce) console.log(`batch ${Math.floor(i / 200) + 1}: ${response.status} ${text.slice(0, 200).replace(/\\n/g, " ")}`);
517
466
  if (!response.ok) break;
@@ -530,9 +479,16 @@ const proofFor = (url, body) => {
530
479
  return { "worktrust-proof": `${seconds}.${nonce}.${cryptoSign(null, Buffer.from(`POST\n${new URL(url).pathname}\n${seconds}\n${nonce}\n${createHash("sha256").update(body).digest("hex")}`), device).toString("base64url")}` };
531
480
  };
532
481
 
482
+ /**
483
+ * WHICH CLIENT A LINE CAME FROM, FOR THE DOOR (2026-10-04). Through the bridge one key serves every app
484
+ * on this computer, and the door names a coupling's clients by the user-agent it hears (mcp_identify's
485
+ * agents_seen). An Antigravity line names Antigravity: the product name, nothing of the work.
486
+ */
487
+ const clientOf = (source) => (source === "antigravity" ? { "user-agent": "antigravity/hook (worktrust-hook)" } : {});
488
+
533
489
  async function send(door, payload) {
534
490
  const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "log_work", arguments: payload } });
535
- const response = await fetch(door.url, { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${door.token}`, ...proofFor(door.url, body) }, body });
491
+ const response = await fetch(door.url, { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${door.token}`, ...clientOf(entrySource.get(payload)), ...proofFor(door.url, body) }, body });
536
492
  if (!response.ok) throw new Error(`door said ${response.status}`);
537
493
  return response.text();
538
494
  }
@@ -551,7 +507,7 @@ async function send(door, payload) {
551
507
  * matches commits by, and cutting on the calendar keeps `at` a real instant rather than an
552
508
  * average of one. Younger than the live window is left alone — that is `log_work`'s half.
553
509
  */
554
- function historyEntries() {
510
+ function historyEntries(floor = null) {
555
511
  const entries = [];
556
512
  for (const { file } of allTranscripts()) {
557
513
  const lines = parsedLines(file);
@@ -570,6 +526,8 @@ function historyEntries() {
570
526
  for (const [day, messages] of byDay) {
571
527
  if (messages.length === 0) continue;
572
528
  messages.sort((a, b) => a.at - b.at);
529
+ // Under-reporting is the safe side (THE FLOORS): a day that began before the account changed is not offered at all.
530
+ if (floor !== null && messages[0].at < floor) continue;
573
531
  const last = messages[messages.length - 1].at;
574
532
  // The live window belongs to the hook: a stretch that log_work may still report is not history.
575
533
  if (Date.now() - last < WINDOW_HOURS * 3600_000) continue;
@@ -588,6 +546,7 @@ function historyEntries() {
588
546
  from: messages[0].at, to: last,
589
547
  });
590
548
  if (CODEX_ROLLOUT.test(file)) entrySource.set(entry, "codex");
549
+ if (ANTIGRAVITY_TRANSCRIPT.test(file)) entrySource.set(entry, "antigravity");
591
550
  entryFile.set(entry, file);
592
551
  entries.push(entry);
593
552
  void day;
@@ -647,9 +606,10 @@ function hookInput() {
647
606
  // comes, and a hook that hangs is worse than one that does nothing. A dry run READS it, so the
648
607
  // hook path is the one a person can rehearse: `echo '<the hook JSON>' | node log-session.mjs
649
608
  // --dry-run` shows exactly what that session would send.
650
- if (process.stdin.isTTY) return null;
609
+ // Antigravity's Stop hook hands its JSON over in the environment (worktrust.mjs hook --antigravity-stop).
610
+ if (process.stdin.isTTY && !process.env.WORKTRUST_HOOK_INPUT) return null;
651
611
  try {
652
- const raw = readFileSync(0, "utf8");
612
+ const raw = process.env.WORKTRUST_HOOK_INPUT ?? readFileSync(0, "utf8");
653
613
  return raw.trim() ? JSON.parse(raw) : null;
654
614
  } catch { return null; }
655
615
  }
@@ -670,13 +630,21 @@ if (flag("uninstall")) {
670
630
  }
671
631
  const removed = before !== JSON.stringify(HOOK_EVENTS.map((event) => settings.hooks?.[event] ?? []));
672
632
  if (removed) writeFileSync(file, JSON.stringify(settings, null, 2));
673
- if (flag("purge")) for (const path of [COUNTER, PINNED, join(homedir(), ".worktrust", "log-session.mjs"), STATE]) { try { rmSync(path); } catch { /* already gone */ } }
633
+ if (flag("purge")) for (const path of [COUNTER, PINNED, join(homedir(), ".worktrust", "log-session.mjs"), STATE, READERS_HOME, join(homedir(), ".worktrust", "antigravity.json")]) { try { rmSync(path); } catch { /* already gone */ } }
674
634
  console.log(`${removed ? "removed" : "nothing wired"}: ${file}${flag("purge") ? "\n purged ~/.worktrust" : ""}`);
675
635
  console.log("the coupling itself still exists: end it in the app (Sources → the computer's row → end this coupling)");
676
636
  process.exit(0);
677
637
  }
678
638
 
679
639
  if (flag("install")) {
640
+ // `--from-now` (every new coupling) and `--history-floor now` (a coupling to another account): see THE FLOORS.
641
+ if (flag("from-now") || value("history-floor") === "now") {
642
+ const floors = readState();
643
+ const now = new Date().toISOString();
644
+ if (flag("from-now")) floors.__from = now;
645
+ if (value("history-floor") === "now") floors.__historyFloor = now;
646
+ writeState(floors);
647
+ }
680
648
  // Wired to SessionEnd, not to every reply: the door asks for one call per PIECE of work, and a
681
649
  // hook that fired per message would report a hundred stretches for one afternoon.
682
650
  //
@@ -730,6 +698,7 @@ if (flag("install")) {
730
698
  // The counting half, from the same deployment the hook came from: one command installs both, or
731
699
  // says plainly that it could not — a half nobody knows is missing is the worse outcome.
732
700
  const counterOrigin = coupling()?.url ? new URL(coupling().url).origin : null;
701
+ if (!(await installReaders(counterOrigin))) console.log("readers: transcript-readers.mjs not found beside this file nor at the deployment; Codex and Antigravity are not read until it is");
733
702
  if (existsSync(COUNTER)) await refreshCounter(counterOrigin, (line) => console.log(line));
734
703
  if (!existsSync(COUNTER)) {
735
704
  const origin = counterOrigin;
@@ -750,10 +719,10 @@ if (flag("install")) {
750
719
  console.log(repaired
751
720
  ? `repaired: ${file} pointed at ${repaired}\n it now runs ${command}`
752
721
  : `installed: ${home}\n ${file} → ${HOOK_EVENTS.join(", ")} run it\n the file you downloaded may be deleted; this copy is the one that runs`);
753
- // Codex has no settings.json to wire; its notify hook is one line in config.toml, added by hand so
754
- // this script never rewrites a TOML file it did not create. Without it, Codex rollouts are still
755
- // swept whenever the Claude Code hook runs.
756
- if (existsSync(join(homedir(), ".codex"))) console.log(`codex: to have Codex wake this hook too, add to ~/.codex/config.toml:\n notify = ${via ? `["${process.env.WORKTRUST_NODE || process.execPath}", "${via}", "hook", "--codex-notify"]` : `["node", "${home}", "--codex-notify"]`}\n (its rollouts are swept on every run either way)`);
722
+ // Codex has no settings.json to wire; its notify hook is one line in config.toml, which this script
723
+ // never rewrites: `npx worktrust` asks and writes it (WORKTRUST_CODEX_OFFER, so no hint twice), and
724
+ // without it Codex rollouts are still swept whenever the Claude Code hook runs.
725
+ if (existsSync(join(homedir(), ".codex")) && !process.env.WORKTRUST_CODEX_OFFER) console.log(`codex: to have Codex wake this hook too, add to ~/.codex/config.toml:\n notify = ${via ? `["${process.env.WORKTRUST_NODE || process.execPath}", "${via}", "hook", "--codex-notify"]` : `["node", "${home}", "--codex-notify"]`}\n (its rollouts are swept on every run either way)`);
757
726
  process.exit(0);
758
727
  }
759
728
 
@@ -766,12 +735,13 @@ const door = coupling();
766
735
  */
767
736
  const codexDoor = codexCoupling();
768
737
  const doorFor = (entry) => (String(entry?.id ?? "").startsWith("codex:") ? codexDoor ?? door : door);
769
- const entrySource = new WeakMap(); // a history entry → "codex" when its rollout was Codex's; sendHistory routes by it
738
+ const entrySource = new WeakMap(); // an entry → "codex" or "antigravity" by the client that wrote it; the sends route and name by it
770
739
  const entryFile = new WeakMap(); // a history entry → its transcript, so `--summary` can count sessions
771
740
  if (!door && !codexDoor && !DRY) { process.exit(0); } // Not coupled on this machine: nothing to do, quietly.
772
741
 
773
742
  if (flag("history")) {
774
- const entries = historyEntries();
743
+ const historyFloor = floorOf(readState(), "__historyFloor");
744
+ const entries = historyEntries(historyFloor);
775
745
  const hours = entries.reduce((sum, entry) => sum + (entry.seconds ?? 0), 0) / 3600;
776
746
  const months = [...new Set(entries.map((entry) => entry.at.slice(0, 7)))].sort();
777
747
  // ONE LINE FOR `npx worktrust` TO ASK WITH (2026-10-03): sessions, hours, first and last month; nothing sent.
@@ -793,17 +763,33 @@ const work = [];
793
763
  if (input?.transcript_path && existsSync(input.transcript_path) && !excluded(input.transcript_path)) {
794
764
  work.push({ id: String(input.session_id ?? input.transcript_path), file: input.transcript_path });
795
765
  }
766
+ // ANTIGRAVITY'S STOP (camelCase, antigravity.google/docs/hooks/): its model and folder are kept for the sweep; no text is in it.
767
+ if (typeof input?.transcriptPath === "string" && ANTIGRAVITY_TRANSCRIPT.test(input.transcriptPath) && existsSync(input.transcriptPath)) {
768
+ const id = rememberAntigravity(input);
769
+ const { cwd } = id ? antigravityContext(id) : {};
770
+ if (id && !(cwd && excluded(cwd))) work.push({ id: `antigravity:${id}`, file: input.transcriptPath });
771
+ }
796
772
  // A session that ended with the terminal closed fires no hook, so every run also sweeps the
797
773
  // transcripts that have gone quiet and were never logged. Self-healing, and it can only ever
798
774
  // report a stretch it can measure.
799
775
  for (const found of candidates()) if (!work.some((entry) => entry.file === found.file)) work.push(found);
800
776
 
777
+ // A computer coupled by an older CLI has no `__from`: it is set to the first watermark this computer
778
+ // ever wrote (when it first sent), or to now on a computer that has sent nothing, and kept.
779
+ if (floorOf(state, "__from") === null) {
780
+ const marks = Object.entries(state).filter(([key, entry]) => !key.startsWith("__") && entry?.through).map(([, entry]) => Date.parse(entry.through)).filter(Number.isFinite);
781
+ state.__from = new Date(marks.length > 0 ? Math.min(...marks) : Date.now()).toISOString();
782
+ if (!DRY) writeState(state);
783
+ }
784
+ const floor = floorOf(state, "__from");
801
785
  let sent = 0;
802
786
  let filed = 0;
803
787
  /** Measured days the live door will no longer take. They are not dropped; they are filed. */
804
788
  const late = [];
805
789
  for (const entry of work) {
806
- const since = state[entry.id]?.through ? Date.parse(state[entry.id].through) : null;
790
+ const mark = state[entry.id]?.through ? Date.parse(state[entry.id].through) : null;
791
+ // Never before the coupling (THE FLOORS): a transcript with no watermark starts at `__from`, not at its first line.
792
+ const since = mark !== null && floor !== null ? Math.max(mark, floor) : mark ?? floor;
807
793
  // Nothing has been written since we last read this one — no need to open it at all. This is what
808
794
  // keeps a sweep over every transcript on the machine to one `stat` each.
809
795
  if (since !== null && entry.writtenAt && entry.writtenAt <= since) continue;
@@ -816,8 +802,9 @@ for (const entry of work) {
816
802
  // every day but today, on whichever run comes first — it no longer waits for its own end.
817
803
  if (Date.now() - stretch.to < IDLE_BEFORE_SWEEP) break;
818
804
  const payload = payloadFor(stretch);
819
- if (String(entry.id).startsWith("codex:")) entrySource.set(payload, "codex");
820
- if (DRY) { console.log(JSON.stringify({ ...payload, route: Date.now() - stretch.to > WINDOW_HOURS * 3600_000 ? "history" : "log_work" }, null, 1)); through = stretch.to; continue; }
805
+ const source = /^(codex|antigravity):/.exec(String(entry.id))?.[1];
806
+ if (source) entrySource.set(payload, source);
807
+ if (DRY) { console.log(JSON.stringify({ ...payload, client: source ?? "claude", route: Date.now() - stretch.to > WINDOW_HOURS * 3600_000 ? "history" : "log_work" }, null, 1)); through = stretch.to; continue; }
821
808
  // MEASURED TIME IS NEVER DROPPED. The live door takes work as it happens and refuses anything
822
809
  // older than two days — which is right, and used to mean a machine that was closed for a
823
810
  // fortnight lost the fortnight. The same measured numbers go through the history door instead:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worktrust",
3
- "version": "0.6.6",
3
+ "version": "0.6.9",
4
4
  "description": "Couple this computer to WorkTrust: approve a code in your browser, and every AI app here reports what you built. Metadata only, no dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,7 @@
11
11
  "setup-mcp.mjs",
12
12
  "log-session.mjs",
13
13
  "count-behaviour.mjs",
14
+ "transcript-readers.mjs",
14
15
  "README.md",
15
16
  "SECURITY.md",
16
17
  "LICENSE"
@@ -25,6 +26,7 @@
25
26
  "mcp",
26
27
  "claude-code",
27
28
  "codex",
29
+ "antigravity",
28
30
  "cursor",
29
31
  "ai",
30
32
  "provenance"
package/setup-mcp.mjs CHANGED
@@ -8,14 +8,14 @@
8
8
  * node setup-mcp.mjs --remove --write (takes it out again)
9
9
  *
10
10
  * One machine, one token, many clients: Claude Code, Cursor, Codex, Gemini CLI, VS Code's
11
- * Copilot agent mode, Windsurf. Each keeps its own config file in its own shape; this finds
11
+ * Copilot agent mode, Windsurf, Mistral Vibe, Google Antigravity, Grok Build, Zed, Cline. Each keeps its own config file in its own shape; this finds
12
12
  * the ones that exist here and adds the same WorkTrust server to each, so a coupling made once
13
13
  * counts everywhere — and every client names itself when it first speaks, which is how Sources
14
14
  * shows each product as coupled. Without --write nothing is touched. The token is shown
15
15
  * masked; it lives only in the files it is written to. A config that already names the server
16
16
  * is left as it is.
17
17
  */
18
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
19
19
  import { spawnSync } from "node:child_process";
20
20
  import { homedir, platform } from "node:os";
21
21
  import { dirname, join } from "node:path";
@@ -68,7 +68,11 @@ const stdio = { command: NODE, args: [BRIDGE ?? "", "mcp"] };
68
68
  const home = homedir();
69
69
  /** Whether Claude Code's own command is on this machine: asked of the shell, never guessed from a file. */
70
70
  const claudeOnPath = () => spawnSync(platform() === "win32" ? "where" : "which", ["claude"], { encoding: "utf8", shell: platform() === "win32" }).status === 0;
71
+ const VIBE_DIR = process.env.VIBE_HOME?.trim() || join(home, ".vibe");
72
+ const GROK_DIR = process.env.GROK_HOME?.trim() || join(home, ".grok");
73
+ const ZED_DIR = platform() === "win32" ? join(process.env.APPDATA ?? home, "Zed") : join(home, ".config", "zed");
71
74
  const vscodeUser = platform() === "darwin" ? join(home, "Library", "Application Support", "Code", "User") : platform() === "win32" ? join(process.env.APPDATA ?? home, "Code", "User") : join(home, ".config", "Code", "User");
75
+ const CLINE_DIR = join(vscodeUser, "globalStorage", "saoudrizwan.claude-dev", "settings");
72
76
 
73
77
  /** Each client: where it lives, how it says "an HTTP MCP server with a header". */
74
78
  const CLIENTS = [
@@ -77,6 +81,21 @@ const CLIENTS = [
77
81
  { key: "gemini", label: "Gemini CLI", present: () => existsSync(join(home, ".gemini")), file: join(home, ".gemini", "settings.json"), shape: "json", root: "mcpServers", entry: BRIDGE ? stdio : { httpUrl: URL_, headers: { Authorization: `Bearer ${TOKEN}` } } },
78
82
  { key: "copilot", label: "VS Code (Copilot agent mode)", present: () => existsSync(vscodeUser), file: join(vscodeUser, "mcp.json"), shape: "json", root: "servers", entry: BRIDGE ? { type: "stdio", ...stdio } : { type: "http", url: URL_, headers: { Authorization: `Bearer ${TOKEN}` } } },
79
83
  { key: "windsurf", label: "Windsurf", present: () => existsSync(join(home, ".codeium", "windsurf")), file: join(home, ".codeium", "windsurf", "mcp_config.json"), shape: "json", root: "mcpServers", entry: BRIDGE ? stdio : { serverUrl: URL_, headers: { Authorization: `Bearer ${TOKEN}` } } },
84
+ // MISTRAL VIBE (2026-10-04, docs.mistral.ai/vibe/code/cli/mcp-servers): `[[mcp_servers]]` tables in
85
+ // ~/.vibe/config.toml (or $VIBE_HOME), each with a name, a transport and a url or a command.
86
+ { key: "vibe", label: "Mistral Vibe", present: () => existsSync(VIBE_DIR), file: join(VIBE_DIR, "config.toml"), shape: "vibe", block: BRIDGE ? `\n[[mcp_servers]]\nname = "${NAME}"\ntransport = "stdio"\ncommand = ${JSON.stringify(NODE)}\nargs = [${JSON.stringify(BRIDGE)}, "mcp"]\n` : `\n[[mcp_servers]]\nname = "${NAME}"\ntransport = "streamable-http"\nurl = "${URL_}"\nheaders = { "Authorization" = "Bearer ${TOKEN}" }\n` },
87
+ // GOOGLE ANTIGRAVITY (2026-10-04, antigravity.google/docs/mcp/): the 2.0 app, the `agy` CLI and the IDE read
88
+ // ~/.gemini/config/mcp_config.json; a remote server is `serverUrl` with headers (`url` and `httpUrl` are not
89
+ // read), a local one `command` and `args`. Present when its config folder or one of its data folders is here.
90
+ { key: "antigravity", label: "Antigravity", present: () => existsSync(join(home, ".gemini", "config")) || (() => { try { return readdirSync(join(home, ".gemini")).some((name) => /^antigravity/.test(name)); } catch { return false; } })(), file: join(home, ".gemini", "config", "mcp_config.json"), shape: "json", root: "mcpServers", entry: BRIDGE ? stdio : { serverUrl: URL_, headers: { Authorization: `Bearer ${TOKEN}` } } },
91
+ // GROK BUILD (2026-10-04, docs.x.ai/build/settings/reference): `[mcp_servers.<name>]` in ~/.grok/config.toml (or
92
+ // $GROK_HOME), `url` and `headers` for a remote server, `command` and `args` for a local one.
93
+ { key: "grok", label: "Grok Build", present: () => existsSync(GROK_DIR), file: join(GROK_DIR, "config.toml"), shape: "toml", block: BRIDGE ? `\n[mcp_servers.${NAME}]\ncommand = ${JSON.stringify(NODE)}\nargs = [${JSON.stringify(BRIDGE)}, "mcp"]\n` : `\n[mcp_servers.${NAME}]\nurl = "${URL_}"\nheaders = { Authorization = "Bearer ${TOKEN}" }\n` },
94
+ // ZED (zed.dev/docs/ai/mcp): `context_servers` in its settings.json, `url` and `headers` for a remote server.
95
+ { key: "zed", label: "Zed", present: () => existsSync(ZED_DIR), file: join(ZED_DIR, "settings.json"), shape: "json", root: "context_servers", entry: BRIDGE ? { ...stdio, env: {} } : { url: URL_, headers: { Authorization: `Bearer ${TOKEN}` } } },
96
+ // CLINE (docs.cline.bot/mcp): `mcpServers` in the extension's cline_mcp_settings.json; `type` must be
97
+ // "streamableHttp" exactly, or Cline falls back to SSE and the door answers 405.
98
+ { key: "cline", label: "Cline (VS Code)", present: () => existsSync(CLINE_DIR), file: join(CLINE_DIR, "cline_mcp_settings.json"), shape: "json", root: "mcpServers", entry: BRIDGE ? { ...stdio, disabled: false } : { type: "streamableHttp", url: URL_, headers: { Authorization: `Bearer ${TOKEN}` }, disabled: false } },
80
99
  { key: "claude", label: "Claude Code", present: () => existsSync(join(home, ".claude.json")), file: join(home, ".claude.json"), shape: "claude" },
81
100
  ];
82
101
 
@@ -121,7 +140,8 @@ for (const client of CLIENTS) {
121
140
  if (!already) { console.log(` – ${client.label}: does not name the door`); continue; }
122
141
  if (client.shape === "json") {
123
142
  let config = {};
124
- try { config = JSON.parse(readFileSync(client.file, "utf8")); } catch { config = {}; }
143
+ // A file that does not parse (comments, a trailing comma: Zed and VS Code allow both) is never rewritten.
144
+ try { config = JSON.parse(readFileSync(client.file, "utf8")); } catch { console.log(` ! ${client.label}: ${client.file} is not plain JSON; take the "${NAME}" entry out by hand`); continue; }
125
145
  const root = config[client.root] ?? {};
126
146
  for (const [key, entry] of Object.entries(root)) {
127
147
  const named = JSON.stringify(entry ?? {}).includes(URL_) || /worktrust\.mjs/.test(JSON.stringify(entry ?? {}));
@@ -130,6 +150,13 @@ for (const client of CLIENTS) {
130
150
  config[client.root] = root;
131
151
  console.log(` − ${client.label}: ${WRITE ? "removed from" : "would remove from"} ${client.file}`);
132
152
  if (WRITE) writeFileSync(client.file, JSON.stringify(config, null, 2) + "\n");
153
+ } else if (client.shape === "vibe") {
154
+ // Vibe's servers are an ARRAY of tables, so a block is found by what it says, not by its header: every
155
+ // `[[mcp_servers]]` that names ours, the door, or the bridge goes, up to the next header at a line start.
156
+ const text = readFileSync(client.file, "utf8");
157
+ const stripped = text.replace(/\n?\[\[mcp_servers\]\][\s\S]*?(?=\n\[|$)/g, (block) => (block.includes(`name = "${NAME}"`) || block.includes(URL_) || /worktrust\.mjs/.test(block) ? "" : block));
158
+ console.log(` − ${client.label}: ${WRITE ? "removed from" : "would remove from"} ${client.file}`);
159
+ if (WRITE) writeFileSync(client.file, stripped);
133
160
  } else if (client.shape === "toml") {
134
161
  const text = readFileSync(client.file, "utf8");
135
162
  // The block and nothing after it: from its own header to the next one, or the end.
@@ -146,12 +173,19 @@ for (const client of CLIENTS) {
146
173
  if (already) { console.log(` ✓ ${client.label}: already names the door (${client.file})`); standing += 1; continue; }
147
174
  if (client.shape === "json") {
148
175
  let config = {};
149
- try { config = JSON.parse(readFileSync(client.file, "utf8")); } catch { config = {}; }
176
+ // NEVER OVERWRITE WHAT DOES NOT PARSE (2026-10-04): Zed's and VS Code's settings allow comments and trailing
177
+ // commas, and reading such a file as empty would write the person's settings away. An empty or absent file is
178
+ // a new one; anything else that fails to parse is left alone and the entry is printed to add by hand.
179
+ const raw = existsSync(client.file) ? readFileSync(client.file, "utf8") : "";
180
+ try { config = raw.trim() ? JSON.parse(raw) : {}; } catch {
181
+ console.log(` ! ${client.label}: ${client.file} is not plain JSON (comments?), so it is left as it is. Add this under "${client.root}" yourself:\n "${NAME}": ${JSON.stringify(client.entry).replace(/Bearer [^"]+/, "Bearer <token>")}`);
182
+ continue;
183
+ }
150
184
  config[client.root] = { ...(config[client.root] ?? {}), [NAME]: client.entry };
151
185
  console.log(` + ${client.label}: ${WRITE ? "wrote" : "would write"} ${client.file} → ${client.root}.${NAME}`);
152
186
  if (WRITE) { mkdirSync(dirname(client.file), { recursive: true }); writeFileSync(client.file, JSON.stringify(config, null, 2) + "\n"); touched += 1; }
153
- } else if (client.shape === "toml") {
154
- console.log(` + ${client.label}: ${WRITE ? "appended to" : "would append to"} ${client.file} → [mcp_servers.${NAME}]`);
187
+ } else if (client.shape === "toml" || client.shape === "vibe") {
188
+ console.log(` + ${client.label}: ${WRITE ? "appended to" : "would append to"} ${client.file} → ${client.shape === "vibe" ? `[[mcp_servers]] name = "${NAME}"` : `[mcp_servers.${NAME}]`}`);
155
189
  if (WRITE) { mkdirSync(dirname(client.file), { recursive: true }); writeFileSync(client.file, (existsSync(client.file) ? readFileSync(client.file, "utf8") : "") + client.block); touched += 1; }
156
190
  }
157
191
  }