worktrust 0.6.7 → 0.6.10

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,64 @@ 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 live beside this file: Codex and Antigravity in transcript-readers.mjs (the counter imports it too, one
200
+ * reader), the database clients in session-databases.mjs. The install copies both into ~/.worktrust with this hook.
201
+ * Without them this hook still reads Claude Code; the never-matching patterns keep every other client out.
203
202
  */
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;
203
+ const { CODEX_ROLLOUT = /(?!)/, codexLines, codexRolloutFiles = function* () {}, codexCwd = () => null, ANTIGRAVITY_TRANSCRIPT = /(?!)/, antigravityLines, antigravityRoots = () => [], antigravityTranscripts = function* () {}, antigravityContext = () => ({}), rememberAntigravity = () => null } = (await import("./transcript-readers.mjs").catch(() => null)) ?? {};
204
+ const { DATABASE_SESSION = /(?!)/, databaseLines = () => null, databaseSessions = function* () {} } = (await import("./session-databases.mjs").catch(() => null)) ?? {};
205
+ const DATABASE_CLIENTS = ["hermes", "goose", "opencode", "openclaw"]; // the registry's keys, the name each line carries
206
+ const READER_FILES = ["transcript-readers.mjs", "session-databases.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) {
209
+ let installed = true;
210
+ for (const name of READER_FILES) { // Hermes, Goose, OpenCode and OpenClaw are read by session-databases.mjs
211
+ const here = join(dirname(fileURLToPath(import.meta.url)), name), home = join(homedir(), ".worktrust", name);
212
+ try {
213
+ if (!fromNetwork && existsSync(here)) { mkdirSync(dirname(home), { recursive: true }); if (here !== home) copyFileSync(here, home); continue; }
214
+ const response = origin ? await fetch(`${origin}/counter/${name}`) : null;
215
+ if (response?.ok) writeFileSync(home, await response.text()); else installed = false;
216
+ } catch { installed = false; }
265
217
  }
218
+ return installed;
266
219
  }
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; }
270
- 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); }
277
- }
278
- // ── end codex rollout reader ──
279
220
 
280
221
  /** The roots this machine's transcripts live under; a test names its own. */
281
222
  const claudeRoot = () => value("transcript-root") ?? join(homedir(), ".claude", "projects");
282
223
  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. */
224
+ /** Every Codex rollout and Antigravity conversation on this machine, an excluded project's threads skipped by their working directory. */
284
225
  function* codexRollouts() {
285
226
  const root = codexRoot();
286
- if (!root) return;
287
- for (const file of codexRolloutFiles(root)) {
227
+ for (const file of root ? codexRolloutFiles(root) : []) {
288
228
  const cwd = codexCwd(file);
289
229
  if (cwd && excluded(cwd)) continue;
290
230
  yield { id: `codex:${file.split("/").at(-1).replace(/\.jsonl$/, "")}`, file };
291
231
  }
232
+ yield* antigravityConversations();
233
+ yield* databaseConversations();
234
+ }
235
+ /** The attended sessions of the clients that keep a database (session-databases.mjs); an excluded folder skipped. */
236
+ function* databaseConversations() {
237
+ const given = Object.fromEntries(DATABASE_CLIENTS.map((client) => [client, value(`${client}-root`)]).filter(([, root]) => root));
238
+ for (const { client, sessionId, file, cwd, writtenAt } of databaseSessions(given, Boolean(value("transcript-root")))) {
239
+ if (!(cwd && excluded(cwd))) yield { id: `${client}:${sessionId}`, file, writtenAt };
240
+ }
241
+ }
242
+ /** Antigravity beside Codex: one transcript per conversation, its folder known only from the Stop hook. */
243
+ function* antigravityConversations() {
244
+ const given = value("antigravity-root");
245
+ for (const { conversationId, file } of antigravityTranscripts(given || !value("transcript-root") ? antigravityRoots(given) : [])) {
246
+ const { cwd } = antigravityContext(conversationId);
247
+ if (!(cwd && excluded(cwd))) yield { id: `antigravity:${conversationId}`, file };
248
+ }
292
249
  }
293
250
  /** A transcript's lines as objects, whichever client wrote it; null when the file cannot be read. */
294
251
  const parsedLines = (file) => {
252
+ if (DATABASE_SESSION.test(file)) return databaseLines(file);
295
253
  let lines; try { lines = readFileSync(file, "utf8").split("\n"); } catch { return null; }
296
254
  if (CODEX_ROLLOUT.test(file)) return [...codexLines(lines)];
255
+ if (ANTIGRAVITY_TRANSCRIPT.test(file)) { const id = file.split("/").at(-4); return [...antigravityLines(lines, { id, ...antigravityContext(id) })]; }
297
256
  const out = [];
298
257
  for (const raw of lines) { if (!raw.trim()) continue; try { out.push(JSON.parse(raw)); } catch { /* a torn line */ } }
299
258
  return out;
@@ -365,9 +324,10 @@ function stretchesOf(file, since) {
365
324
  const lines = parsedLines(file);
366
325
  if (!lines) return [];
367
326
  const messages = [];
368
- let cwd = null;
327
+ let cwd = null, steered = STEERED_HERE;
369
328
  for (const line of lines) {
370
329
  if (typeof line.cwd === "string") cwd = line.cwd;
330
+ if (typeof line.steered === "string") steered = line.steered; // a client that says how it was steered (Hermes's source)
371
331
  if (!line.timestamp || (line.type !== "user" && line.type !== "assistant")) continue;
372
332
  const at = Date.parse(line.timestamp);
373
333
  if (!Number.isFinite(at)) continue;
@@ -382,7 +342,7 @@ function stretchesOf(file, since) {
382
342
  if (!byDay.has(day)) byDay.set(day, []);
383
343
  byDay.get(day).push(message);
384
344
  }
385
- return [...byDay.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, ofDay]) => measure(cwd, ofDay)).filter(Boolean);
345
+ return [...byDay.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([day, ofDay]) => { const stretch = measure(cwd, ofDay); return stretch && { ...stretch, ref: stretchRef(file, day), steered }; }).filter(Boolean);
386
346
  }
387
347
 
388
348
  /**
@@ -460,6 +420,11 @@ function tokensOf(messages) {
460
420
  return { tokensIn, tokensOut, cacheRead, cacheWrite, shapeKnown: counted > 0 };
461
421
  }
462
422
 
423
+ // WHICH STRETCH, HOW STEERED (0.6.10): a hash of the session id (the file name) and the day, the same on any computer, so a
424
+ // restored backup is never counted twice; `remote` only when the session runs over SSH, otherwise nothing is said.
425
+ const stretchRef = (file, day) => createHash("sha256").update(`worktrust-stretch|${String(file).split(/[\\/]/).pop().replace(/\.jsonl?$/, "")}|${day}`).digest("base64url");
426
+ const STEERED_HERE = process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY ? "remote" : null;
427
+
463
428
  /** The one payload shape this script can build. Every key is a number, a date or an enum value. */
464
429
  function payloadFor(stretch) {
465
430
  const cwd = stretch.cwd && existsSync(stretch.cwd) ? stretch.cwd : null;
@@ -489,6 +454,7 @@ function payloadFor(stretch) {
489
454
  ...(stretch.model ? { model: stretch.model } : {}),
490
455
  ...(repo ? { repo } : {}),
491
456
  ...(touched.shas.length > 0 ? { commits: touched.shas.slice(0, 50) } : {}),
457
+ ...(stretch.ref ? { stretch_ref: stretch.ref } : {}), ...(stretch.steered ? { steered_from: stretch.steered } : {}),
492
458
  at: new Date(stretch.to).toISOString(),
493
459
  };
494
460
  }
@@ -503,15 +469,16 @@ function payloadFor(stretch) {
503
469
  async function sendHistory(door, entries, announce = false) {
504
470
  let accepted = 0;
505
471
  // 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")]]) {
472
+ // through the door given: the record files a line under the coupling that sent it. Antigravity's
473
+ // go apart too, so their batch can name the client (clientOf).
474
+ for (const [source, group] of [["claude", entries.filter((entry) => !entrySource.has(entry))], ["codex", entries.filter((entry) => entrySource.get(entry) === "codex")], ...["antigravity", ...DATABASE_CLIENTS].map((client) => [client, entries.filter((entry) => entrySource.get(entry) === client)])]) {
508
475
  const target = source === "codex" ? codexDoor ?? door : door;
509
476
  if (group.length === 0 || !target) continue;
510
477
  for (let i = 0; i < group.length; i += 200) {
511
478
  const batch = group.slice(i, i + 200);
512
479
  // History carries no shas: an imported line forms no session, and the join is the live route's.
513
480
  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 });
481
+ 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
482
  const text = await response.text();
516
483
  if (announce) console.log(`batch ${Math.floor(i / 200) + 1}: ${response.status} ${text.slice(0, 200).replace(/\\n/g, " ")}`);
517
484
  if (!response.ok) break;
@@ -530,9 +497,16 @@ const proofFor = (url, body) => {
530
497
  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
498
  };
532
499
 
500
+ /**
501
+ * WHICH CLIENT A LINE CAME FROM, FOR THE DOOR (2026-10-04). Through the bridge one key serves every app
502
+ * on this computer, and the door names a coupling's clients by the user-agent it hears (mcp_identify's
503
+ * agents_seen). An Antigravity line names Antigravity: the product name, nothing of the work.
504
+ */
505
+ const clientOf = (source) => (source === "antigravity" || DATABASE_CLIENTS.includes(source) ? { "user-agent": `${source}/hook (worktrust-hook)` } : {});
506
+
533
507
  async function send(door, payload) {
534
508
  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 });
509
+ 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
510
  if (!response.ok) throw new Error(`door said ${response.status}`);
537
511
  return response.text();
538
512
  }
@@ -551,15 +525,16 @@ async function send(door, payload) {
551
525
  * matches commits by, and cutting on the calendar keeps `at` a real instant rather than an
552
526
  * average of one. Younger than the live window is left alone — that is `log_work`'s half.
553
527
  */
554
- function historyEntries() {
528
+ function historyEntries(floor = null) {
555
529
  const entries = [];
556
530
  for (const { file } of allTranscripts()) {
557
531
  const lines = parsedLines(file);
558
532
  if (!lines) continue;
559
- let cwd = null;
533
+ let cwd = null, steered = null;
560
534
  const byDay = new Map();
561
535
  for (const line of lines) {
562
536
  if (typeof line.cwd === "string") cwd = line.cwd;
537
+ if (typeof line.steered === "string") steered = line.steered;
563
538
  if (!line.timestamp || (line.type !== "user" && line.type !== "assistant")) continue;
564
539
  const at = Date.parse(line.timestamp);
565
540
  if (!Number.isFinite(at)) continue;
@@ -570,6 +545,8 @@ function historyEntries() {
570
545
  for (const [day, messages] of byDay) {
571
546
  if (messages.length === 0) continue;
572
547
  messages.sort((a, b) => a.at - b.at);
548
+ // Under-reporting is the safe side (THE FLOORS): a day that began before the account changed is not offered at all.
549
+ if (floor !== null && messages[0].at < floor) continue;
573
550
  const last = messages[messages.length - 1].at;
574
551
  // The live window belongs to the hook: a stretch that log_work may still report is not history.
575
552
  if (Date.now() - last < WINDOW_HOURS * 3600_000) continue;
@@ -585,12 +562,13 @@ function historyEntries() {
585
562
  const entry = payloadFor({
586
563
  cwd, seconds: seconds >= MIN_SECONDS ? Math.min(MAX_SECONDS, seconds) : null,
587
564
  tokensIn, tokensOut, cacheRead, cacheWrite, shapeKnown, exchanges, model: [...models.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null,
588
- from: messages[0].at, to: last,
565
+ from: messages[0].at, to: last, ref: stretchRef(file, day), steered,
589
566
  });
590
567
  if (CODEX_ROLLOUT.test(file)) entrySource.set(entry, "codex");
568
+ if (ANTIGRAVITY_TRANSCRIPT.test(file)) entrySource.set(entry, "antigravity");
569
+ if (DATABASE_SESSION.test(file)) entrySource.set(entry, file.slice(0, file.indexOf(":")));
591
570
  entryFile.set(entry, file);
592
571
  entries.push(entry);
593
- void day;
594
572
  }
595
573
  }
596
574
  return entries.sort((a, b) => a.at.localeCompare(b.at));
@@ -622,6 +600,7 @@ function* allTranscripts() {
622
600
  */
623
601
  function* candidates() {
624
602
  for (const rollout of codexRollouts()) {
603
+ if (rollout.writtenAt) { yield rollout; continue; } // a database session knows its own last moment
625
604
  let stat; try { stat = statSync(rollout.file); } catch { continue; }
626
605
  yield { ...rollout, writtenAt: stat.mtimeMs };
627
606
  }
@@ -647,9 +626,10 @@ function hookInput() {
647
626
  // comes, and a hook that hangs is worse than one that does nothing. A dry run READS it, so the
648
627
  // hook path is the one a person can rehearse: `echo '<the hook JSON>' | node log-session.mjs
649
628
  // --dry-run` shows exactly what that session would send.
650
- if (process.stdin.isTTY) return null;
629
+ // Antigravity's Stop hook hands its JSON over in the environment (worktrust.mjs hook --antigravity-stop).
630
+ if (process.stdin.isTTY && !process.env.WORKTRUST_HOOK_INPUT) return null;
651
631
  try {
652
- const raw = readFileSync(0, "utf8");
632
+ const raw = process.env.WORKTRUST_HOOK_INPUT ?? readFileSync(0, "utf8");
653
633
  return raw.trim() ? JSON.parse(raw) : null;
654
634
  } catch { return null; }
655
635
  }
@@ -670,13 +650,21 @@ if (flag("uninstall")) {
670
650
  }
671
651
  const removed = before !== JSON.stringify(HOOK_EVENTS.map((event) => settings.hooks?.[event] ?? []));
672
652
  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 */ } }
653
+ if (flag("purge")) for (const path of [COUNTER, PINNED, join(homedir(), ".worktrust", "log-session.mjs"), STATE, ...READER_FILES.map((name) => join(homedir(), ".worktrust", name)), join(homedir(), ".worktrust", "antigravity.json")]) { try { rmSync(path); } catch { /* already gone */ } }
674
654
  console.log(`${removed ? "removed" : "nothing wired"}: ${file}${flag("purge") ? "\n purged ~/.worktrust" : ""}`);
675
655
  console.log("the coupling itself still exists: end it in the app (Sources → the computer's row → end this coupling)");
676
656
  process.exit(0);
677
657
  }
678
658
 
679
659
  if (flag("install")) {
660
+ // `--from-now` (every new coupling) and `--history-floor now` (a coupling to another account): see THE FLOORS.
661
+ if (flag("from-now") || value("history-floor") === "now") {
662
+ const floors = readState();
663
+ const now = new Date().toISOString();
664
+ if (flag("from-now")) floors.__from = now;
665
+ if (value("history-floor") === "now") floors.__historyFloor = now;
666
+ writeState(floors);
667
+ }
680
668
  // Wired to SessionEnd, not to every reply: the door asks for one call per PIECE of work, and a
681
669
  // hook that fired per message would report a hundred stretches for one afternoon.
682
670
  //
@@ -730,6 +718,7 @@ if (flag("install")) {
730
718
  // The counting half, from the same deployment the hook came from: one command installs both, or
731
719
  // says plainly that it could not — a half nobody knows is missing is the worse outcome.
732
720
  const counterOrigin = coupling()?.url ? new URL(coupling().url).origin : null;
721
+ if (!(await installReaders(counterOrigin))) console.log("readers: transcript-readers.mjs or session-databases.mjs not found beside this file nor at the deployment; the clients they read are not read until they are");
733
722
  if (existsSync(COUNTER)) await refreshCounter(counterOrigin, (line) => console.log(line));
734
723
  if (!existsSync(COUNTER)) {
735
724
  const origin = counterOrigin;
@@ -750,10 +739,10 @@ if (flag("install")) {
750
739
  console.log(repaired
751
740
  ? `repaired: ${file} pointed at ${repaired}\n it now runs ${command}`
752
741
  : `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)`);
742
+ // Codex has no settings.json to wire; its notify hook is one line in config.toml, which this script
743
+ // never rewrites: `npx worktrust` asks and writes it (WORKTRUST_CODEX_OFFER, so no hint twice), and
744
+ // without it Codex rollouts are still swept whenever the Claude Code hook runs.
745
+ 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
746
  process.exit(0);
758
747
  }
759
748
 
@@ -766,12 +755,13 @@ const door = coupling();
766
755
  */
767
756
  const codexDoor = codexCoupling();
768
757
  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
758
+ const entrySource = new WeakMap(); // an entry → "codex" or "antigravity" by the client that wrote it; the sends route and name by it
770
759
  const entryFile = new WeakMap(); // a history entry → its transcript, so `--summary` can count sessions
771
760
  if (!door && !codexDoor && !DRY) { process.exit(0); } // Not coupled on this machine: nothing to do, quietly.
772
761
 
773
762
  if (flag("history")) {
774
- const entries = historyEntries();
763
+ const historyFloor = floorOf(readState(), "__historyFloor");
764
+ const entries = historyEntries(historyFloor);
775
765
  const hours = entries.reduce((sum, entry) => sum + (entry.seconds ?? 0), 0) / 3600;
776
766
  const months = [...new Set(entries.map((entry) => entry.at.slice(0, 7)))].sort();
777
767
  // ONE LINE FOR `npx worktrust` TO ASK WITH (2026-10-03): sessions, hours, first and last month; nothing sent.
@@ -793,17 +783,33 @@ const work = [];
793
783
  if (input?.transcript_path && existsSync(input.transcript_path) && !excluded(input.transcript_path)) {
794
784
  work.push({ id: String(input.session_id ?? input.transcript_path), file: input.transcript_path });
795
785
  }
786
+ // ANTIGRAVITY'S STOP (camelCase, antigravity.google/docs/hooks/): its model and folder are kept for the sweep; no text is in it.
787
+ if (typeof input?.transcriptPath === "string" && ANTIGRAVITY_TRANSCRIPT.test(input.transcriptPath) && existsSync(input.transcriptPath)) {
788
+ const id = rememberAntigravity(input);
789
+ const { cwd } = id ? antigravityContext(id) : {};
790
+ if (id && !(cwd && excluded(cwd))) work.push({ id: `antigravity:${id}`, file: input.transcriptPath });
791
+ }
796
792
  // A session that ended with the terminal closed fires no hook, so every run also sweeps the
797
793
  // transcripts that have gone quiet and were never logged. Self-healing, and it can only ever
798
794
  // report a stretch it can measure.
799
795
  for (const found of candidates()) if (!work.some((entry) => entry.file === found.file)) work.push(found);
800
796
 
797
+ // A computer coupled by an older CLI has no `__from`: it is set to the first watermark this computer
798
+ // ever wrote (when it first sent), or to now on a computer that has sent nothing, and kept.
799
+ if (floorOf(state, "__from") === null) {
800
+ const marks = Object.entries(state).filter(([key, entry]) => !key.startsWith("__") && entry?.through).map(([, entry]) => Date.parse(entry.through)).filter(Number.isFinite);
801
+ state.__from = new Date(marks.length > 0 ? Math.min(...marks) : Date.now()).toISOString();
802
+ if (!DRY) writeState(state);
803
+ }
804
+ const floor = floorOf(state, "__from");
801
805
  let sent = 0;
802
806
  let filed = 0;
803
807
  /** Measured days the live door will no longer take. They are not dropped; they are filed. */
804
808
  const late = [];
805
809
  for (const entry of work) {
806
- const since = state[entry.id]?.through ? Date.parse(state[entry.id].through) : null;
810
+ const mark = state[entry.id]?.through ? Date.parse(state[entry.id].through) : null;
811
+ // Never before the coupling (THE FLOORS): a transcript with no watermark starts at `__from`, not at its first line.
812
+ const since = mark !== null && floor !== null ? Math.max(mark, floor) : mark ?? floor;
807
813
  // Nothing has been written since we last read this one — no need to open it at all. This is what
808
814
  // keeps a sweep over every transcript on the machine to one `stat` each.
809
815
  if (since !== null && entry.writtenAt && entry.writtenAt <= since) continue;
@@ -816,8 +822,9 @@ for (const entry of work) {
816
822
  // every day but today, on whichever run comes first — it no longer waits for its own end.
817
823
  if (Date.now() - stretch.to < IDLE_BEFORE_SWEEP) break;
818
824
  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; }
825
+ const source = /^(codex|antigravity|hermes|goose|opencode|openclaw):/.exec(String(entry.id))?.[1];
826
+ if (source) entrySource.set(payload, source);
827
+ 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
828
  // MEASURED TIME IS NEVER DROPPED. The live door takes work as it happens and refuses anything
822
829
  // older than two days — which is right, and used to mean a machine that was closed for a
823
830
  // 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.7",
3
+ "version": "0.6.10",
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,9 @@
11
11
  "setup-mcp.mjs",
12
12
  "log-session.mjs",
13
13
  "count-behaviour.mjs",
14
+ "transcript-readers.mjs",
15
+ "session-databases.mjs",
16
+ "config-edits.mjs",
14
17
  "README.md",
15
18
  "SECURITY.md",
16
19
  "LICENSE"
@@ -25,6 +28,7 @@
25
28
  "mcp",
26
29
  "claude-code",
27
30
  "codex",
31
+ "antigravity",
28
32
  "cursor",
29
33
  "ai",
30
34
  "provenance"