worktrust 0.6.15 → 0.6.16

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/README.md CHANGED
@@ -38,7 +38,7 @@ yourself run a command that sends. See [Keep your history](#keep-your-history).
38
38
  3. **The terminal finishes by itself.** It keeps the key in its private file and gives every
39
39
  AI app here the WorkTrust door, then installs the session hook. Quit your AI apps and open them
40
40
  again.
41
- 4. **Earlier work only if you say yes.** It counts the Claude Code and Codex sessions already on
41
+ 4. **Earlier work only if you say yes.** It counts your AI apps' sessions already on
42
42
  this computer and asks `Send these as history? [y/N]`. Enter sends nothing. What goes is hours
43
43
  and tokens per day, never text, and WorkTrust shows it as earlier work, never as verified hours.
44
44
  Nothing from before the coupling is ever sent without that yes: the session hook starts at the
@@ -202,11 +202,11 @@ which would delete every password on the computer, and many people rightly keep
202
202
 
203
203
  | | |
204
204
  |---|---|
205
- | **Reads** | Which AI apps are installed (the settings folders of Claude Code, Codex, Cursor, Gemini CLI, Antigravity, VS Code, Windsurf, Hermes Agent, Goose, OpenCode, OpenClaw), and the chats Cursor and Copilot keep (clocks, models and token counts only). Afterwards, the session hook reads Claude Code's, Codex's and Antigravity's session files and the session databases of Hermes, Goose, OpenCode and OpenClaw on this computer to measure durations and token counts. |
205
+ | **Reads** | Which AI apps are installed (the settings folders of Claude Code, Codex, Cursor, Gemini CLI, Antigravity, VS Code, Windsurf, Hermes Agent, Goose, OpenCode, OpenClaw), and the chats Cursor and Copilot keep (clocks, models and token counts only). Afterwards, the session hook reads Claude Code's, Codex's and Antigravity's session files, the session databases of Hermes, Goose, OpenCode and OpenClaw, and the chats Cursor and Copilot keep, on this computer to measure durations and token counts. |
206
206
  | **Writes** | The key file (above). In each AI app's MCP settings (`~/.claude.json` through `claude mcp`, `~/.cursor/mcp.json`, `~/.codex/config.toml`, `~/.gemini/settings.json`, `~/.gemini/config/mcp_config.json` (Antigravity), VS Code's `mcp.json`, Windsurf's `mcp_config.json`, Hermes's and Goose's `config.yaml`, OpenCode's `opencode.json`, OpenClaw through `openclaw mcp set`) a WorkTrust entry that runs the local bridge, with no key in it; three hooks in `~/.claude/settings.json`; the bridge, the hook and the counter in `~/.worktrust/`. |
207
207
  | **Sends, to pair** | This computer's system (macOS, Windows, Linux) and host name. WorkTrust also records the network address the request arrives from, as a security record kept 90 days and seen only by WorkTrust staff (to answer a theft or fraud report). |
208
- | **Sends, afterwards** | Durations, token counts, model names, one layer keyword (frontend, backend …), counts of how you work. |
209
- | **Never sends** | Prompts, answers, code, file names or paths, commit messages, branch or project names. The door has no field for them and refuses a submission that carries them. |
208
+ | **Sends, afterwards** | Per measured stretch of work, and nothing else: its durations (`seconds`, `duration_basis`, `model_seconds`, `tool_seconds`, `human_seconds`, `idle_seconds`), token counts (`tokens_in`, `tokens_out`, `tokens_cache_read`, `tokens_cache_write`, `token_basis`), the model's name (`model`), the kind of work and one layer keyword such as frontend or backend (`title`, `kind`, `layer`, `layers`), counts of how you work (`exchanges`, `turn_basis`, `agent_runs`, `agent_seconds`, `agent_peak`), when it ended (`at`), a hash of the session and the day (`stretch_ref`), `steered_from` when it ran over SSH, and, so the work can be matched to GitHub, the repository as its remote's owner/name (`repo`) and the hashes of the commits made in the stretch (`commits`, never their messages). |
209
+ | **Never sends** | Prompts, answers, code, file names or paths, commit messages, branch names, or the name of your local folder (a repository is named only as its remote's owner/name, above). The door has no field for them and refuses a submission that carries them. |
210
210
 
211
211
  ## Why you can check it
212
212
 
package/config-edits.mjs CHANGED
@@ -67,3 +67,18 @@ export function takeOut(lines, path, mine) {
67
67
  if (left && !lines.slice(left.at + 1, left.end).some(meaningful)) lines.splice(left.at, left.end - left.at);
68
68
  }
69
69
  }
70
+
71
+ /**
72
+ * ONLY THE HOUSE'S OWN DOOR (0.6.16, audit L5; the same rule as log-session.mjs and count-behaviour.mjs): an MCP address is
73
+ * WorkTrust's when its path is /api/mcp and its host is worktrust.io or a subdomain over https, the host WORKTRUST_MCP_URL
74
+ * names, or this computer. `/api/mcp` alone is every hosted MCP server's conventional path, so another product's entry is
75
+ * never adopted as the coupling, and never taken out with it.
76
+ */
77
+ export const isWorkTrustDoor = (url) => {
78
+ try {
79
+ const parsed = new URL(String(url));
80
+ if (!/\/api\/mcp\/?$/.test(parsed.pathname)) return false;
81
+ const own = process.env.WORKTRUST_MCP_URL ? new URL(process.env.WORKTRUST_MCP_URL).host : null;
82
+ return parsed.protocol === "https:" && (parsed.hostname === "worktrust.io" || parsed.hostname.endsWith(".worktrust.io")) || (own !== null && parsed.host === own) || parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
83
+ } catch { return false; }
84
+ };
package/log-session.mjs CHANGED
@@ -38,8 +38,8 @@
38
38
  * `worktrust.mjs hook --antigravity-stop`. The readers live in transcript-readers.mjs beside this file.
39
39
  *
40
40
  * WHAT CANNOT LEAVE. The payload is built from a fixed allowlist of keys, all of them numbers,
41
- * dates or vocabulary from the door's own enums. No prompt, no answer, no file path, no commit
42
- * message, no branch, no project name. File paths ARE read locally to decide one layer keyword,
41
+ * dates or vocabulary from the door's own enums, and the repository as owner/name and commit hashes. No prompt, no
42
+ * answer, no file path, no commit message, no branch, no folder name. File paths ARE read locally to decide one layer keyword,
43
43
  * the way the connector reads them to decide an activity class, and only the keyword travels.
44
44
  *
45
45
  * WHAT IS MEASURED, AND WHAT IS THEREFORE NOT SENT. A stretch's seconds are the sum of the gaps
@@ -209,9 +209,10 @@ const excluded = (path) => excludes.some((text) => text && path.includes(text));
209
209
  * reader), the database clients in session-databases.mjs. The install copies both into ~/.worktrust with this hook.
210
210
  * Without them this hook still reads Claude Code; the never-matching patterns keep every other client out.
211
211
  */
212
- const { CODEX_ROLLOUT = /(?!)/, codexLines, codexRolloutFiles = function* () {}, codexCwd = () => null, ANTIGRAVITY_TRANSCRIPT = /(?!)/, antigravityLines, antigravityRoots = () => [], antigravityTranscripts = function* () {}, antigravityContext = () => ({}), rememberAntigravity = () => null } = (await import("./transcript-readers.mjs").catch(() => null)) ?? {};
212
+ const { CODEX_ROLLOUT = /(?!)/, codexIdOf = (file) => String(file).split(/[\\/]/).at(-1).replace(/\.jsonl$/, ""), antigravityIdOf = (file) => String(file).split(/[\\/]/).at(-4), codexLines, codexRolloutFiles = function* () {}, codexCwd = () => null, ANTIGRAVITY_TRANSCRIPT = /(?!)/, antigravityLines, antigravityRoots = () => [], antigravityTranscripts = function* () {}, antigravityContext = () => ({}), rememberAntigravity = () => null } = (await import("./transcript-readers.mjs").catch(() => null)) ?? {};
213
213
  const { DATABASE_SESSION = /(?!)/, databaseLines = () => null, databaseSessions = function* () {} } = (await import("./session-databases.mjs").catch(() => null)) ?? {};
214
214
  const DATABASE_CLIENTS = ["hermes", "goose", "opencode", "openclaw", "cursor", "copilot"]; // the registry's keys, the name each line carries
215
+ const LIVE_SOURCE = new RegExp(`^(codex|antigravity|${DATABASE_CLIENTS.join("|")}):`); // a sweep entry's id → the client it names (every database client, 0.6.16)
215
216
  const READER_FILES = ["transcript-readers.mjs", "session-databases.mjs"];
216
217
  /** The readers go where the hook and the counter run: copied from beside this file, else (or for a newer counter) from the deployment. */
217
218
  async function installReaders(origin, fromNetwork = false) {
@@ -236,7 +237,7 @@ function* codexRollouts() {
236
237
  for (const file of root ? codexRolloutFiles(root) : []) {
237
238
  const cwd = codexCwd(file);
238
239
  if (cwd && excluded(cwd)) continue;
239
- yield { id: `codex:${file.split("/").at(-1).replace(/\.jsonl$/, "")}`, file };
240
+ yield { id: `codex:${codexIdOf(file)}`, file };
240
241
  }
241
242
  yield* antigravityConversations();
242
243
  yield* databaseConversations();
@@ -261,7 +262,7 @@ const parsedLines = (file) => {
261
262
  if (DATABASE_SESSION.test(file)) return databaseLines(file);
262
263
  let lines; try { lines = readFileSync(file, "utf8").split("\n"); } catch { return null; }
263
264
  if (CODEX_ROLLOUT.test(file)) return [...codexLines(lines)];
264
- if (ANTIGRAVITY_TRANSCRIPT.test(file)) { const id = file.split("/").at(-4); return [...antigravityLines(lines, { id, ...antigravityContext(id) })]; }
265
+ if (ANTIGRAVITY_TRANSCRIPT.test(file)) { const id = antigravityIdOf(file); return [...antigravityLines(lines, { id, ...antigravityContext(id) })]; }
265
266
  const out = [];
266
267
  for (const raw of lines) { if (!raw.trim()) continue; try { out.push(JSON.parse(raw)); } catch { /* a torn line */ } }
267
268
  return out;
@@ -365,7 +366,7 @@ const messageOf = (line, at) => {
365
366
  toolUse: line.type === "assistant" && blocks.some((block) => block && block.type === "tool_use"),
366
367
  // A tool that waits for the PERSON (a question, a plan to approve): the gap to its result is the person's, not the tool's. The name is read, never the input.
367
368
  waitsForPerson: line.type === "assistant" && blocks.some((block) => block && block.type === "tool_use" && WAITS_FOR_PERSON.has(String(block.name))),
368
- agentic: line.agentic === true,
369
+ agentic: line.agentic === true, cumulative: line.cumulative === true, // the session's running totals (Hermes, Goose without a ledger)
369
370
  };
370
371
  };
371
372
 
@@ -463,7 +464,7 @@ function measure(cwd, messages) {
463
464
  }
464
465
  const model = [...models.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
465
466
  const tokens = tokensOf(messages);
466
- return { cwd, seconds: measured, layers: measured !== null ? timeLayers(messages) : null, ...tokens, exchanges, model, from: messages[0].at, to: messages[messages.length - 1].at };
467
+ return { cwd, seconds: measured, layers: measured !== null ? timeLayers(messages) : null, ...tokens, cumulative: messages.some((message) => message.cumulative), exchanges, model, from: messages[0].at, to: messages[messages.length - 1].at };
467
468
  }
468
469
 
469
470
  /**
@@ -523,10 +524,15 @@ function tokensOf(messages) {
523
524
  if (!shapeKnown) return { tokensIn: 0, tokensOut: 0, cacheRead: 0, cacheWrite: 0, shapeKnown: false };
524
525
  return { tokensIn, tokensOut, cacheRead, cacheWrite, shapeKnown: counted > 0 };
525
526
  }
526
-
527
- // 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
528
- // restored backup is never counted twice; `remote` only when the session runs over SSH, otherwise nothing is said.
529
- const stretchRef = (file, day) => createHash("sha256").update(`worktrust-stretch|${String(file).split(/[\\/]/).pop().replace(/\.jsonl?$/, "")}|${day}`).digest("base64url");
527
+ // A SESSION'S TOTALS ARE SENT ONCE (0.6.16, audit H2). Hermes, and Goose without its ledger, keep tokens per SESSION on its last answer, so a resumed
528
+ // session sent its whole total again. Such a stretch sends what the total grew by since the one reported (kept beside the watermark), never a negative.
529
+ const TOTALS = ["tokensIn", "tokensOut", "cacheRead", "cacheWrite"];
530
+ const grownBy = (stretch, reported) => { if (!stretch.cumulative || !stretch.shapeKnown) return reported; const total = Object.fromEntries(TOTALS.map((key) => [key, Math.max(stretch[key], reported?.[key] ?? 0)]));
531
+ for (const key of TOTALS) stretch[key] = Math.max(0, stretch[key] - (reported?.[key] ?? 0)); return total; }; // the total to keep; a fresh session sends its total, as before
532
+
533
+ // WHICH STRETCH, HOW STEERED (0.6.10): a hash of the session id (the file name; Antigravity's, always transcript.jsonl, its conversation's folder) and the day,
534
+ // the same on any computer, so a restored backup is never counted twice; `remote` only when the session runs over SSH, otherwise nothing is said.
535
+ const stretchRef = (file, day) => createHash("sha256").update(`worktrust-stretch|${ANTIGRAVITY_TRANSCRIPT.test(file) ? antigravityIdOf(file) : String(file).split(/[\\/]/).pop().replace(/\.jsonl?$/, "")}|${day}`).digest("base64url");
530
536
  const STEERED_HERE = process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY ? "remote" : null;
531
537
 
532
538
  /** The one payload shape this script can build. Every key is a number, a date or an enum value. */
@@ -668,7 +674,8 @@ function historyEntries(floor = null, all = false) {
668
674
  entries.push(entry);
669
675
  }
670
676
  }
671
- return entries.sort((a, b) => a.at.localeCompare(b.at));
677
+ const seen = new Set(); // ONE SESSION FILE IN TWO PROJECT FOLDERS (a copied folder) IS ONE STRETCH (0.6.16, audit M6), as live and preserve count it
678
+ return entries.filter((entry) => !seen.has(entry.stretch_ref) && seen.add(entry.stretch_ref)).sort((a, b) => a.at.localeCompare(b.at));
672
679
  }
673
680
 
674
681
  /** Every transcript this machine holds, excluded projects skipped before a file is opened. */
@@ -901,51 +908,44 @@ if (floorOf(state, "__from") === null) {
901
908
  if (!DRY) writeState(state);
902
909
  }
903
910
  const floor = floorOf(state, "__from");
904
- let sent = 0;
905
- let filed = 0;
906
- /** Measured days the live door will no longer take. They are not dropped; they are filed. */
907
- const late = [];
911
+ let sent = 0, filed = 0;
912
+ const late = []; // measured days the live door will no longer take: they are not dropped, they are filed
913
+ const heldBack = new Map(); // per transcript with a late day: its state before that day, put back unless history took every late day
908
914
  for (const entry of work) {
909
915
  const mark = state[entry.id]?.through ? Date.parse(state[entry.id].through) : null;
910
916
  // Never before the coupling (THE FLOORS): a transcript with no watermark starts at `__from`, not at its first line.
911
917
  const since = mark !== null && floor !== null ? Math.max(mark, floor) : mark ?? floor;
912
- // Nothing has been written since we last read this one — no need to open it at all. This is what
913
- // keeps a sweep over every transcript on the machine to one `stat` each.
918
+ // Nothing written since we last read this one: it is not opened. This keeps a sweep over every transcript to one `stat` each.
914
919
  if (since !== null && entry.writtenAt && entry.writtenAt <= since) continue;
915
920
  const stretches = stretchesOf(entry.file, since);
916
921
  // THE WATERMARK ADVANCES OVER WHAT WAS ACCEPTED, AND STOPS AT THE FIRST REFUSAL. A door that is
917
922
  // down must never cost the person a day, so the loop breaks rather than skipping ahead.
918
- let through = null;
923
+ let through = null, before, reported = state[entry.id]?.reported;
919
924
  for (const stretch of stretches) {
920
925
  // The day still being worked in is not finished. A fortnight-long session therefore reports
921
926
  // every day but today, on whichever run comes first — it no longer waits for its own end.
922
927
  if (Date.now() - stretch.to < IDLE_BEFORE_SWEEP) break;
928
+ const total = grownBy(stretch, reported);
923
929
  const payload = payloadFor(stretch);
924
- const source = /^(codex|antigravity|hermes|goose|opencode|openclaw):/.exec(String(entry.id))?.[1];
930
+ const source = LIVE_SOURCE.exec(String(entry.id))?.[1];
925
931
  if (source) entrySource.set(payload, source);
926
- 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; }
927
- // MEASURED TIME IS NEVER DROPPED. The live door takes work as it happens and refuses anything
928
- // older than two days — which is right, and used to mean a machine that was closed for a
929
- // fortnight lost the fortnight. The same measured numbers go through the history door instead:
930
- // recorded, marked as history, standing as history stands. Late is not the same as untrue.
931
- if (Date.now() - stretch.to > WINDOW_HOURS * 3600_000) { late.push(payload); through = stretch.to; continue; }
932
- try {
933
- await send(doorFor(entry), payload);
934
- through = stretch.to;
935
- sent += 1;
936
- } catch (error) {
932
+ 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; reported = total; continue; }
933
+ // MEASURED TIME IS NEVER DROPPED. The live door refuses anything older than two days, which is right, and used to mean a machine closed
934
+ // for a fortnight lost the fortnight. The same numbers go through the history door instead, standing as history stands. Late is not untrue.
935
+ if (Date.now() - stretch.to > WINDOW_HOURS * 3600_000) { before ??= { mark: through === null ? state[entry.id] : { ...state[entry.id], through: new Date(through).toISOString() } }; late.push(payload); through = stretch.to; reported = total; continue; }
936
+ try { await send(doorFor(entry), payload); through = stretch.to; reported = total; sent += 1; } catch (error) {
937
937
  if (process.env.WORKTRUST_HOOK_DEBUG) console.error(`worktrust: ${error.message}`);
938
938
  break;
939
939
  }
940
940
  }
941
- if (through !== null) state[entry.id] = { through: new Date(through).toISOString() };
941
+ if (before && !heldBack.has(entry.id)) heldBack.set(entry.id, before.mark);
942
+ if (through !== null) state[entry.id] = { through: new Date(through).toISOString(), ...(reported ? { reported } : {}) };
942
943
  }
943
- // The history door, in one batch per two hundred, exactly as `--history` sends them. A resend
944
- // FILLS a line that was missing something rather than adding a twin, so a repeated run is safe.
944
+ // The history door, in batches of two hundred as `--history` sends them. A resend FILLS a line that was missing something, never a twin.
945
945
  if (!DRY && late.length > 0) {
946
- try { filed = await sendHistory(door, late); } catch (error) {
947
- if (process.env.WORKTRUST_HOOK_DEBUG) console.error(`worktrust: ${error.message}`);
948
- }
946
+ try { filed = await sendHistory(door, late); } catch (error) { if (process.env.WORKTRUST_HOOK_DEBUG) console.error(`worktrust: ${error.message}`); }
947
+ // A LATE DAY THE HISTORY DOOR REFUSED IS NOT PASSED: its transcript's mark goes back to before it, so the next run sends it again.
948
+ if (filed < late.length) for (const [id, mark] of heldBack) { if (mark) state[id] = mark; else delete state[id]; }
949
949
  }
950
950
  if (!DRY && (sent > 0 || filed > 0)) writeState(state);
951
951
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worktrust",
3
- "version": "0.6.15",
3
+ "version": "0.6.16",
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": {
@@ -33,6 +33,7 @@ export const hermesSteered = (source) => (HERMES_DESK.has(source) ? "desk" : HER
33
33
  let sqlite;
34
34
  /** A database session's pseudo-file, `<client>:<database>#<session id>`, back into its parts. */
35
35
  const splitSession = (pseudo) => { const at = pseudo.lastIndexOf("#"); return { file: pseudo.slice(pseudo.indexOf(":") + 1, at), id: pseudo.slice(at + 1) }; };
36
+ const isoOf = (value) => { const at = new Date(value); return Number.isFinite(at.getTime()) ? at.toISOString() : null; }; // null: that row is skipped, never the session (0.6.16, M2)
36
37
  const openDatabase = (file) => {
37
38
  if (sqlite === undefined) {
38
39
  // node:sqlite says it is experimental on some Node versions; that line is not the person's business inside a hook.
@@ -85,10 +86,10 @@ export function hermesLines(pseudo) {
85
86
  const out = [{ type: "meta", ...(typeof session.cwd === "string" ? { cwd: session.cwd } : {}), steered: hermesSteered(session.source) }];
86
87
  const lastAssistant = messages.map((m) => m.role).lastIndexOf("assistant");
87
88
  messages.forEach((message, i) => {
88
- const timestamp = new Date(Number(message.timestamp) * 1000).toISOString();
89
+ const timestamp = isoOf(Number(message.timestamp) * 1000); if (!timestamp) return;
89
90
  if (message.role === "user") out.push({ type: "user", timestamp, message: { content: [{ type: "text" }] } });
90
91
  else if (message.role === "tool") out.push({ type: "user", timestamp, message: { content: [{ type: "tool_result" }] } });
91
- else if (message.role === "assistant") out.push({ type: "assistant", timestamp, message: { model, ...(i === lastAssistant ? { usage: { input_tokens: Number(session.input_tokens) || 0, output_tokens: Number(session.output_tokens) || 0, cache_read_input_tokens: Number(session.cache_read_tokens) || 0, cache_creation_input_tokens: Number(session.cache_write_tokens) || 0 } } : {}), content: [] } });
92
+ else if (message.role === "assistant") out.push({ type: "assistant", timestamp, ...(i === lastAssistant ? { cumulative: true } : {}), message: { model, ...(i === lastAssistant ? { usage: { input_tokens: Number(session.input_tokens) || 0, output_tokens: Number(session.output_tokens) || 0, cache_read_input_tokens: Number(session.cache_read_tokens) || 0, cache_creation_input_tokens: Number(session.cache_write_tokens) || 0 } } : {}), content: [] } });
92
93
  });
93
94
  return out;
94
95
  } catch { return null; } finally { db.close(); }
@@ -146,16 +147,15 @@ export function gooseLines(pseudo) {
146
147
  const messages = db.prepare(`select role, created_timestamp as at,
147
148
  case when json_valid(content_json) then (select group_concat(json_extract(value, '$.type')) from json_each(content_json)) end as kinds
148
149
  from messages where session_id = ? order by created_timestamp, id`).all(id);
149
- const ledger = db.prepare("select 1 from sqlite_master where type = 'table' and name = 'usage_ledger'").get()
150
- ? db.prepare("select created_timestamp as at, model, input_tokens as input, output_tokens as output, cache_read_tokens as cache_read, cache_write_tokens as cache_write from usage_ledger where session_id = ? order by created_timestamp, id").all(id)
150
+ const ledger = db.prepare("select 1 from sqlite_master where type = 'table' and name = 'usage_ledger'").get() ? db.prepare("select created_timestamp as at, model, input_tokens as input, output_tokens as output, cache_read_tokens as cache_read, cache_write_tokens as cache_write from usage_ledger where session_id = ? order by created_timestamp, id").all(id)
151
151
  : null;
152
152
  const fallbackModel = cleanModel(session.model);
153
153
  const lines = messages.map((message) => {
154
154
  const kinds = new Set(String(message.kinds ?? "").split(","));
155
- const timestamp = new Date(ms(message.at)).toISOString();
155
+ const timestamp = isoOf(ms(message.at)); if (!timestamp) return null;
156
156
  if (message.role === "user") return { type: "user", timestamp, message: { content: [{ type: kinds.has("toolResponse") ? "tool_result" : kinds.has("text") || kinds.has("image") ? "text" : "tool_result" }] } };
157
157
  return { type: "assistant", timestamp, at: ms(message.at), message: { model: fallbackModel, content: [] } };
158
- });
158
+ }).filter(Boolean);
159
159
  const assistants = lines.filter((line) => line.type === "assistant");
160
160
  const usageOf = (row) => ({ input_tokens: Number(row.input) || 0, output_tokens: Number(row.output) || 0, cache_read_input_tokens: Number(row.cache_read) || 0, cache_creation_input_tokens: Number(row.cache_write) || 0 });
161
161
  const add = (line, usage) => { const was = line.message.usage; line.message.usage = was ? Object.fromEntries(Object.entries(usage).map(([key, n]) => [key, n + (was[key] ?? 0)])) : usage; };
@@ -165,7 +165,7 @@ export function gooseLines(pseudo) {
165
165
  if (!line) continue;
166
166
  add(line, usageOf(row));
167
167
  if (cleanModel(row.model)) line.message.model = cleanModel(row.model);
168
- } else if (assistants.length) add(assistants.at(-1), usageOf(session));
168
+ } else if (assistants.length) { add(assistants.at(-1), usageOf(session)); assistants.at(-1).cumulative = true; } // the session's totals: sent as what they grew by
169
169
  for (const line of assistants) delete line.at;
170
170
  return [{ type: "meta", ...(typeof session.cwd === "string" ? { cwd: session.cwd } : {}), steered: gooseSteered(session.type) }, ...lines];
171
171
  } catch { return null; } finally { db.close(); }
@@ -212,11 +212,11 @@ export function opencodeLines(pseudo) {
212
212
  json_extract(data, '$.tokens.reasoning') as reasoning, json_extract(data, '$.tokens.cache.read') as cache_read, json_extract(data, '$.tokens.cache.write') as cache_write
213
213
  from message where session_id = ? order by time_created, id`).all(id);
214
214
  const lines = messages.map((message) => {
215
- const timestamp = new Date(Number(message.at)).toISOString();
215
+ const timestamp = isoOf(Number(message.at)); if (!timestamp) return null;
216
216
  if (message.role === "user") return { type: "user", timestamp, message: { content: [{ type: "text" }] } };
217
217
  const counted = message.input !== null || message.output !== null;
218
218
  return { type: "assistant", timestamp, message: { model: cleanModel(message.model), ...(counted ? { usage: { input_tokens: Number(message.input) || 0, output_tokens: (Number(message.output) || 0) + (Number(message.reasoning) || 0), cache_read_input_tokens: Number(message.cache_read) || 0, cache_creation_input_tokens: Number(message.cache_write) || 0 } } : {}), content: [] } };
219
- });
219
+ }).filter(Boolean);
220
220
  return [{ type: "meta", ...(typeof session.cwd === "string" ? { cwd: session.cwd } : {}) }, ...lines];
221
221
  } catch { return null; } finally { db.close(); }
222
222
  }
@@ -349,7 +349,7 @@ export function cursorLines(pseudo) {
349
349
  const model = cleanModel(chat?.model);
350
350
  const lines = [];
351
351
  for (const bubble of bubbles.sort((a, b) => String(a.at).localeCompare(String(b.at)))) {
352
- const timestamp = new Date(bubble.at).toISOString();
352
+ const timestamp = isoOf(bubble.at); if (!timestamp) continue;
353
353
  if (Number(bubble.type) === 1) lines.push({ type: "user", timestamp, message: { content: [{ type: "text" }] } });
354
354
  else if (Number(bubble.type) === 2) {
355
355
  const usage = Number(bubble.input) || Number(bubble.output) ? { usage: { input_tokens: Number(bubble.input) || 0, output_tokens: Number(bubble.output) || 0 } } : {};
package/setup-mcp.mjs CHANGED
@@ -19,7 +19,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync
19
19
  import { spawnSync } from "node:child_process";
20
20
  import { homedir, platform } from "node:os";
21
21
  import { dirname, join } from "node:path";
22
- import { placeUnder, takeOut } from "./config-edits.mjs";
22
+ import { isWorkTrustDoor, placeUnder, takeOut } from "./config-edits.mjs";
23
23
 
24
24
  const args = process.argv.slice(2);
25
25
  const value = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : undefined; };
@@ -39,7 +39,7 @@ function fromClaude() {
39
39
  try {
40
40
  const config = JSON.parse(readFileSync(join(homedir(), ".claude.json"), "utf8"));
41
41
  const found = [];
42
- const walk = (node) => { if (!node || typeof node !== "object") return; if (typeof node.url === "string" && node.url.includes("/api/mcp")) { const auth = node.headers?.Authorization ?? node.headers?.authorization ?? ""; if (auth.startsWith("Bearer ")) found.push({ url: node.url, token: auth.slice(7) }); } for (const child of Object.values(node)) walk(child); };
42
+ const walk = (node) => { if (!node || typeof node !== "object") return; if (typeof node.url === "string" && isWorkTrustDoor(node.url)) { const auth = node.headers?.Authorization ?? node.headers?.authorization ?? ""; if (auth.startsWith("Bearer ")) found.push({ url: node.url, token: auth.slice(7) }); } for (const child of Object.values(node)) walk(child); };
43
43
  walk(config); return found[0] ?? null;
44
44
  } catch { return null; }
45
45
  }
@@ -54,7 +54,7 @@ const discovered = fromClaude();
54
54
  function directClaudeEntries() {
55
55
  try {
56
56
  const config = JSON.parse(readFileSync(join(homedir(), ".claude.json"), "utf8"));
57
- const door = (server) => typeof server?.url === "string" && /\/api\/mcp\b/.test(server.url) && /worktrust/i.test(new URL(server.url).host);
57
+ const door = (server) => typeof server?.url === "string" && isWorkTrustDoor(server.url);
58
58
  const entries = Object.entries(config.mcpServers ?? {}).filter(([, server]) => door(server)).map(([name]) => ({ name, scope: "user", dir: null }));
59
59
  for (const [dir, project] of Object.entries(config.projects ?? {})) for (const [name, server] of Object.entries(project?.mcpServers ?? {})) if (door(server)) entries.push({ name, scope: "local", dir });
60
60
  const bridged = Object.values(config.mcpServers ?? {}).some((server) => Array.isArray(server?.args) && server.args.includes(BRIDGE));
@@ -38,7 +38,10 @@ import { fileURLToPath } from "node:url";
38
38
  * The running total in event_msg/token_count is never read: it is a counter, not a record.
39
39
  * Text is read here for the rubric and the layer and travels nowhere, as with every transcript.
40
40
  */
41
- export const CODEX_ROLLOUT = /(^|\/)rollout-\d{4}-\d{2}-\d{2}T[\d-]+-[0-9a-f-]{36}\.jsonl$/;
41
+ // Either separator (0.6.16, audit H4): on Windows a path reads C:\Users\…\rollout-….jsonl, and a pattern of `/` alone matched none.
42
+ export const CODEX_ROLLOUT = /(^|[\\/])rollout-\d{4}-\d{2}-\d{2}T[\d-]+-[0-9a-f-]{36}\.jsonl$/;
43
+ /** A rollout's thread id: its file's name without .jsonl, split on either separator. */
44
+ export const codexIdOf = (file) => String(file).split(/[\\/]/).at(-1).replace(/\.jsonl$/, "");
42
45
  export const CODEX_HARNESS_TURN = /^\s*<[a-z][a-z_]*[\s>]/i;
43
46
  export 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"));
44
47
  export const codexToolInput = (name, raw) => {
@@ -134,7 +137,9 @@ export function codexCwd(file) {
134
137
  * `thinking` is never read. No usage block is made up: such a stretch is sent without tokens.
135
138
  * A step written twice (RUNNING, then DONE) is read once, on its step_index.
136
139
  */
137
- export const ANTIGRAVITY_TRANSCRIPT = /(^|\/)brain\/[^/]+\/\.system_generated\/logs\/transcript\.jsonl$/;
140
+ export const ANTIGRAVITY_TRANSCRIPT = /(^|[\\/])brain[\\/][^\\/]+[\\/]\.system_generated[\\/]logs[\\/]transcript\.jsonl$/; // either separator (0.6.16, H4)
141
+ /** A transcript's conversation id: the folder under brain/, split on either separator. */
142
+ export const antigravityIdOf = (file) => String(file).split(/[\\/]/).at(-4);
138
143
  const ANTIGRAVITY_SKIPPED = new Set(["CONVERSATION_HISTORY", "CHECKPOINT", "SYSTEM_MESSAGE", "EPHEMERAL_MESSAGE"]);
139
144
  /** A model name as a client names it, or null: the hook's modelName is the only one, and a name is all it may be. */
140
145
  export const cleanModel = (name) => (typeof name === "string" && /^[A-Za-z0-9][A-Za-z0-9._:/ -]{0,63}$/.test(name.trim()) ? name.trim() : null);
package/worktrust.mjs CHANGED
@@ -69,7 +69,7 @@ const command = args.find((arg, at) => !arg.startsWith("--") && !(at > 0 && VALU
69
69
  const flag = (name) => { const at = args.indexOf(`--${name}`); return at >= 0 ? args[at + 1] : undefined; };
70
70
  const has = (name) => args.includes(`--${name}`);
71
71
  /** This CLI's version, said to the door so the app can tell which computer runs an old one (check-cli-package holds it equal to package.json). */
72
- const CLI_VERSION = "0.6.15";
72
+ const CLI_VERSION = "0.6.16";
73
73
  const ORIGIN = (flag("origin") ?? process.env.WORKTRUST_ORIGIN ?? "https://app.worktrust.io").replace(/\/$/, "");
74
74
  const MCP = flag("url") ?? process.env.WORKTRUST_MCP_URL ?? `${ORIGIN}/api/mcp`;
75
75
  const HOME_DIR = join(homedir(), ".worktrust");
@@ -81,6 +81,8 @@ const downloaded = (name) => (IMPORTED.has(name) ? name : `${name}.download.mjs`
81
81
  const BUNDLED = SCRIPTS.every((name) => existsSync(join(HERE, name)));
82
82
  const PLACEHOLDER = `wt_${"0".repeat(43)}`;
83
83
  const say = (line = "") => process.stdout.write(`${line}\n`);
84
+ // A reader that closed the pipe (`worktrust status | head`) wants no more: stop quietly, never an EPIPE stack (0.6.16, audit L4).
85
+ process.stdout.on("error", (error) => { if (error?.code === "EPIPE") process.exit(0); throw error; });
84
86
  const fail = (line) => { process.stderr.write(`worktrust: ${line}\n`); process.exit(1); };
85
87
  const OS = { darwin: "macos", win32: "windows", linux: "linux" }[platform()] ?? "other";
86
88
  const OS_NAME = { macos: "macOS", windows: "Windows", linux: "Linux", other: "this system" }[OS];
@@ -576,7 +578,8 @@ async function connect() {
576
578
  say(` + ${join(HOME_DIR, "log-session.mjs")} and ${join(HOME_DIR, "count-behaviour.mjs")}${BUNDLED ? " (from this package, not updated from the network)" : ""}`);
577
579
  say(` + ${join(homedir(), ".claude", "settings.json")} → SessionStart, SessionEnd and PreCompact run it`);
578
580
  say();
579
- say(" Sent to WorkTrust afterwards: durations, token counts, model names, a layer keyword, counts.");
581
+ say(" Sent to WorkTrust afterwards: durations, token counts, model names, a layer keyword, counts,");
582
+ say(" and, to match the work to GitHub, the repository's owner/name and the commit hashes.");
580
583
  say(" Never sent: prompts, answers, code, file paths, commit messages.");
581
584
  say();
582
585
  if (has("dry-run")) { say(" Dry run: nothing was written and nothing was sent."); return; }
@@ -634,8 +637,8 @@ async function offerHistory(paths, token, devicePem, url = MCP) {
634
637
  let found = null;
635
638
  try { found = JSON.parse(counted.out.trim().split("\n").pop()); } catch { /* no line: nothing to offer */ }
636
639
  say();
637
- if (!found?.sessions) { say(" No earlier Claude Code, Codex or Antigravity sessions on this computer to send."); return; }
638
- say(` This computer holds ${found.sessions} earlier Claude Code / Codex / Antigravity sessions · ${found.hours} measured hours · ${found.first} … ${found.last}.`);
640
+ if (!found?.sessions) { say(" Your AI apps hold no earlier sessions on this computer to send."); return; }
641
+ say(` This computer holds ${found.sessions} earlier sessions of your AI apps · ${found.hours} measured hours · ${found.first} … ${found.last}.`);
639
642
  if (has("dry-run")) { say(" Dry run: nothing was sent."); return; }
640
643
  if (!has("history") && !(await ask("Send these as history (hours and tokens per day; never text)?", false))) { say(" Not sent. Send them later with: npx worktrust@latest history"); return; }
641
644
  const sent = await run(paths["log-session.mjs"], ["--history"], token, true, env);