worktrust 0.6.7 → 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/README.md CHANGED
@@ -35,9 +35,21 @@ curl -fsSO https://app.worktrust.io/counter/worktrust.mjs && node worktrust.mjs
35
35
  4. **Earlier work only if you say yes.** It counts the Claude Code and Codex sessions already on
36
36
  this computer and asks `Send these as history? [y/N]`. Enter sends nothing. What goes is hours
37
37
  and tokens per day, never text, and WorkTrust shows it as earlier work, never as verified hours.
38
+ Nothing from before the coupling is ever sent without that yes: the session hook starts at the
39
+ moment of coupling.
40
+ 5. **Codex only if you say yes.** With Codex on this computer it asks `Let Codex wake the session
41
+ hook? [y/N]` and, on a yes, sets the one `notify` line in `~/.codex/config.toml` (the file is
42
+ kept as `config.toml.worktrust-backup` first). Another program's `notify` is never replaced.
43
+ 6. **Antigravity only if you say yes.** With Google Antigravity on this computer it asks `Let
44
+ Antigravity wake the session hook? [y/N]` and, on a yes, adds one hook named `worktrust` with a
45
+ single `Stop` command to `~/.gemini/config/hooks.json` (kept as `hooks.json.worktrust-backup`
46
+ first). Other hooks in that file stay as they are. Antigravity records no token counts, so its
47
+ sessions arrive with durations and turn counts only.
38
48
 
39
49
  ```
40
50
  npx worktrust history send this computer's earlier sessions later (asks first)
51
+ npx worktrust codex let Codex wake the session hook (asks first)
52
+ npx worktrust antigravity let Antigravity wake the session hook (asks first)
41
53
  npx worktrust status what is coupled here
42
54
  npx worktrust disconnect take it out again (shows the plan, asks first)
43
55
  ```
@@ -65,9 +77,9 @@ which would delete every password on the computer, and many people rightly keep
65
77
 
66
78
  | | |
67
79
  |---|---|
68
- | **Reads** | Which AI apps are installed (the settings folders of Claude Code, Codex, Cursor, Gemini CLI, VS Code, Windsurf). Afterwards, the session hook reads Claude Code's and Codex's own session files on this computer to measure durations and token counts. |
69
- | **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`, VS Code's `mcp.json`, Windsurf's `mcp_config.json`) 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/`. |
70
- | **Sends, to pair** | This computer's system (macOS, Windows, Linux) and host name. Not its network address. |
80
+ | **Reads** | Which AI apps are installed (the settings folders of Claude Code, Codex, Cursor, Gemini CLI, Antigravity, VS Code, Windsurf). Afterwards, the session hook reads Claude Code's, Codex's and Antigravity's own session files on this computer to measure durations and token counts. |
81
+ | **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`) 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/`. |
82
+ | **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). |
71
83
  | **Sends, afterwards** | Durations, token counts, model names, one layer keyword (frontend, backend …), counts of how you work. |
72
84
  | **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. |
73
85
 
@@ -86,10 +98,27 @@ which would delete every password on the computer, and many people rightly keep
86
98
  - **The key** never appears on a command line a process list could show: it is written to its file
87
99
  and reaches the bridge and the hook only in their environment.
88
100
 
101
+ ## A computer that is already coupled, or shared
102
+
103
+ - `npx worktrust connect` on a computer that is already coupled says to which account (masked) and
104
+ asks `Couple it again? [y/N]`. Enter and `--yes` mean No; `--replace` says yes in advance.
105
+ - The browser page that approves it shows, large, which WorkTrust account is signed in, with
106
+ **Use another account**: the computer goes to whoever approves it there.
107
+ - Once the new key is in place, the old key is ended at WorkTrust (the request is signed by this
108
+ computer's own device key). An AI app still open on the old key is refused and moves to the new one.
109
+ - When the new account is a different one, the old account sees a notice that its computer moved,
110
+ with the computer's name and the moment, never the other account; no earlier work on this
111
+ computer is offered to the new account, only work from the moment of coupling.
112
+ - `npx worktrust status` shows the account this computer sends to.
113
+ - Different people on one computer with their own OS user each couple their own: everything lives
114
+ in that user's home folder, and the accounts never meet.
115
+
89
116
  ## Undo
90
117
 
91
- `npx worktrust disconnect` removes the WorkTrust entries, the hook and the key file, and empties
92
- `~/.worktrust`.
118
+ `npx worktrust disconnect` removes the WorkTrust entries, the hook (and Codex's `notify` line and
119
+ Antigravity's `worktrust` hook when WorkTrust set them) and the key file, and empties
120
+ `~/.worktrust` but for one small note of which account it fed (masked), so that a later coupling by
121
+ another account offers none of this computer's earlier work.
93
122
  Then revoke the key in WorkTrust: Sources → Devices → this computer → Revoke.
94
123
 
95
124
  ## Requirements
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * THE LOCAL COUNTER — rubric v0.1 (`counter@0.1.0`).
3
3
  *
4
- * Reads this machine's own Claude Code transcripts, its Codex rollouts (2026-09-27), its VS Code
4
+ * Reads this machine's own Claude Code transcripts, its Codex rollouts (2026-09-27), its Antigravity conversations (2026-10-04), its VS Code
5
5
  * Copilot chat sessions (2026-09-08) and, when given, a claude.ai export (~/.claude/projects/**​/*.jsonl — transcripts
6
6
  * ONLY, never project files), classifies each of the person's OWN messages with the
7
7
  * deterministic rules below, and reports COUNTS per month through the WorkTrust MCP door's
@@ -625,101 +625,12 @@ function* claudeExportSessions() {
625
625
  }
626
626
  }
627
627
  }
628
- // ── codex rollout reader (the same text in count-behaviour.mjs and log-session.mjs; test-codex-rollout holds them equal) ──
629
628
  /**
630
- * A CODEX ROLLOUT, READ AS A TRANSCRIPT (2026-09-27). Codex keeps one JSONL per thread under
631
- * ~/.codex/sessions/YYYY/MM/DD/rollout-<stamp>-<uuid>.jsonl: a session_meta line, then per turn a
632
- * turn_context (model, effort, cwd), response_items (messages, tool calls, their outputs) and a
633
- * token_usage_record per model response. On the machine this was built on five of them held 679
634
- * million tokens that nothing read. This turns the records into the lines Claude Code writes, so
635
- * ONE rubric and ONE clock read both:
636
- * · a user or assistant message → a user or assistant line with a text part; the developer line
637
- * and the harness's own user-role messages (<environment_context>, <recommended_plugins> and
638
- * the like: a tag opening the text) are the harness, not the person, and are dropped;
639
- * · a tool call → an assistant line with a tool_use part: `exec` becomes Bash with the command
640
- * as input, `apply_patch` an Edit with the first path it names, `spawn_agent` an Agent; its
641
- * output → a user line with a tool_result, is_error read from Codex's own "exit code N";
642
- * · a token_usage_record → an assistant line carrying the usage in Claude's keys, ONCE per
643
- * response_id (Codex writes a response more than once): input minus cached is new input,
644
- * cached is a cache read, cache_write a cache write, output already holds the reasoning tokens.
645
- * The running total in event_msg/token_count is never read: it is a counter, not a record.
646
- * Text is read here for the rubric and the layer and travels nowhere, as with every transcript.
629
+ * THE READERS FOR CODEX AND ANTIGRAVITY live in transcript-readers.mjs beside this file (2026-10-04;
630
+ * the session hook imports the same file). Run alone, without it, the counter reads Claude Code,
631
+ * Copilot and an export, and says that Codex and Antigravity were not read.
647
632
  */
648
- const CODEX_ROLLOUT = /(^|\/)rollout-\d{4}-\d{2}-\d{2}T[\d-]+-[0-9a-f-]{36}\.jsonl$/;
649
- const CODEX_HARNESS_TURN = /^\s*<[a-z][a-z_]*[\s>]/i;
650
- 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"));
651
- const codexToolInput = (name, raw) => {
652
- const mapped = codexToolName(name);
653
- if (mapped === "Bash") {
654
- 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 }; }
655
- return { command: Array.isArray(raw?.command) ? raw.command.join(" ") : String(raw?.command ?? raw?.cmd ?? "") };
656
- }
657
- 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() : "" }; }
658
- return {};
659
- };
660
- const codexOutputText = (output) => (typeof output === "string" ? output : Array.isArray(output) ? output.map((part) => (typeof part === "string" ? part : part?.text ?? "")).join("\n") : "");
661
- /** The rollout's raw JSON lines → transcript-shaped line objects, in file order. */
662
- function* codexLines(records) {
663
- let cwd = null, model = null;
664
- const seenResponses = new Set();
665
- for (const raw of records) {
666
- if (!raw || !String(raw).trim()) continue;
667
- let record; try { record = JSON.parse(raw); } catch { continue; }
668
- const timestamp = typeof record?.timestamp === "string" ? record.timestamp : null;
669
- const payload = record?.payload && typeof record.payload === "object" ? record.payload : {};
670
- if (record.type === "session_meta") { if (typeof payload.cwd === "string") cwd = payload.cwd; continue; }
671
- if (record.type === "turn_context") { if (typeof payload.cwd === "string") cwd = payload.cwd; if (typeof payload.model === "string") model = payload.model; continue; }
672
- if (record.type === "token_usage_record") {
673
- const id = String(payload.response_id ?? "");
674
- const usage = payload.usage;
675
- if (!usage || typeof usage !== "object" || (id && seenResponses.has(id))) continue;
676
- if (id) seenResponses.add(id);
677
- const n = (key) => (typeof usage[key] === "number" && Number.isFinite(usage[key]) ? usage[key] : 0);
678
- 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") } } };
679
- continue;
680
- }
681
- if (record.type !== "response_item") continue;
682
- const kind = payload.type;
683
- if (kind === "message") {
684
- if (payload.role !== "user" && payload.role !== "assistant") continue;
685
- 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 : "";
686
- if (payload.role === "user" && CODEX_HARNESS_TURN.test(text)) continue;
687
- yield { type: payload.role, timestamp, cwd, uuid: payload.id ? `codex-${payload.id}` : undefined, message: { ...(payload.role === "assistant" && model ? { model } : {}), content: [{ type: "text", text }] } };
688
- continue;
689
- }
690
- if (kind === "custom_tool_call" || kind === "function_call") {
691
- const input = kind === "function_call" ? (() => { try { return JSON.parse(payload.arguments ?? "{}"); } catch { return {}; } })() : payload.input;
692
- 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) }] } };
693
- continue;
694
- }
695
- if (kind === "custom_tool_call_output" || kind === "function_call_output") {
696
- const text = codexOutputText(payload.output);
697
- const exit = /(?:failed with|exited with|exit code)[:\s]+(-?\d+)/i.exec(text.slice(-400));
698
- 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 }] } };
699
- }
700
- }
701
- }
702
- /** Every rollout under a Codex sessions root, in a stable order. */
703
- function* codexRolloutFiles(root) {
704
- let entries = []; try { entries = readdirSync(root, { withFileTypes: true }); } catch { return; }
705
- for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
706
- const full = join(root, entry.name);
707
- if (entry.isDirectory()) yield* codexRolloutFiles(full);
708
- else if (CODEX_ROLLOUT.test(entry.name)) yield full;
709
- }
710
- }
711
- /** The rollout's working directory, from its first line alone: the file is not read for it. */
712
- function codexCwd(file) {
713
- let fd; try { fd = openSync(file, "r"); } catch { return null; }
714
- try {
715
- const buffer = Buffer.allocUnsafe(64 * 1024);
716
- const read = readSync(fd, buffer, 0, buffer.length, 0);
717
- const first = buffer.toString("utf8", 0, read).split("\n")[0] ?? "";
718
- const meta = JSON.parse(first);
719
- return meta?.type === "session_meta" && typeof meta.payload?.cwd === "string" ? meta.payload.cwd : null;
720
- } catch { return null; } finally { closeSync(fd); }
721
- }
722
- // ── end codex rollout reader ──
633
+ const { codexLines, codexRolloutFiles, codexCwd, antigravityLines, antigravityRoots, antigravityTranscripts, antigravityContext } = (await import("./transcript-readers.mjs").catch(() => null)) ?? {};
723
634
 
724
635
  /**
725
636
  * Codex rollouts, as sessions. The real ~/.codex/sessions is read when nothing was overridden;
@@ -730,7 +641,7 @@ function codexCwd(file) {
730
641
  */
731
642
  let codexRolloutCount = 0;
732
643
  function* codexSessions() {
733
- if (flag("no-codex")) return;
644
+ if (flag("no-codex") || !codexRolloutFiles) return;
734
645
  const given = value("codex-root");
735
646
  if (!given && value("transcript-root")) return;
736
647
  const root = (given ?? join(homedir(), ".codex", "sessions")).replace(/^~(?=\/|$)/, homedir());
@@ -744,6 +655,21 @@ function* codexSessions() {
744
655
  yield { key: file, projectDir, basename, path: file, codex: true };
745
656
  }
746
657
  }
658
+ /** Antigravity conversations (2026-10-04), the same way: --antigravity-root for a test, --no-antigravity to leave them out; the folder the Stop hook named decides --exclude. */
659
+ let antigravityCount = 0;
660
+ function* antigravitySessions() {
661
+ const given = value("antigravity-root");
662
+ if (flag("no-antigravity") || !antigravityTranscripts || (!given && value("transcript-root"))) return;
663
+ for (const { conversationId, file } of antigravityTranscripts(antigravityRoots(given))) {
664
+ const context = antigravityContext(conversationId);
665
+ const basename = context.cwd ? String(context.cwd).split("/").filter(Boolean).at(-1) ?? null : null;
666
+ if (basename && excluded(basename)) { skippedDirs.push(`antigravity:${basename}`); continue; }
667
+ const projectDir = basename ? `antigravity-${basename}` : "antigravity";
668
+ if (!countedDirs.includes(projectDir)) countedDirs.push(projectDir);
669
+ antigravityCount += 1;
670
+ yield { key: file, projectDir, basename, path: file, antigravity: { id: conversationId, ...context } };
671
+ }
672
+ }
747
673
  /** Every session the rubric reads: a transcript file, or one conversation from a claude.ai export. */
748
674
  /**
749
675
  * v0.10: a transcript is READ AS A STREAM of lines. Reading a whole file into one string failed
@@ -782,6 +708,7 @@ function* sessions() {
782
708
  }
783
709
  // A rollout is a transcript on this machine, so --only-transcripts keeps it.
784
710
  yield* codexSessions();
711
+ yield* antigravitySessions();
785
712
  if (!flag("only-transcripts")) { yield* claudeExportSessions(); yield* copilotSessions(); }
786
713
  }
787
714
 
@@ -899,7 +826,7 @@ const seenLines = new Set(); // v0.10: line identities already counted (a uuid o
899
826
  // scan; complete session boundaries preserve signals that depend on earlier turns.
900
827
  const manifest = [];
901
828
  for(const session of sessions())manifest.push(sessionDigest(session));
902
- exportConversations = 0; copilotSessionCount = 0; codexRolloutCount = 0; skippedDirs.length = 0; countedDirs.length = 0; skippedConversations.length = 0;
829
+ exportConversations = 0; copilotSessionCount = 0; codexRolloutCount = 0; antigravityCount = 0; skippedDirs.length = 0; countedDirs.length = 0; skippedConversations.length = 0;
903
830
  const checkpoint = flag("no-checkpoint") || LABEL_OUT ? null : checkpointStore(
904
831
  value("checkpoint") ?? join(homedir(), ".worktrust", "counter-checkpoint.json"),
905
832
  fingerprint({code:readFileSync(new URL(import.meta.url)),version:ANALYZER_VERSION,months:MONTHS_BACK,thisMonth,exclude:EXCLUDE,leaveOut:LEAVE_OUT,exports:EXPORTS}),manifest);
@@ -959,7 +886,7 @@ for (const session of sessions()) {
959
886
  let lineNo = 0;
960
887
  // Streamed either way; a Codex rollout's stream is shaped into transcript lines as it passes.
961
888
  const rawLines = session.path ? fileLines(session.path, streamHash) : session.lines;
962
- for (const entry of session.codex ? codexLines(rawLines) : rawLines) {
889
+ for (const entry of session.codex ? codexLines(rawLines) : session.antigravity ? antigravityLines(rawLines, session.antigravity) : rawLines) {
963
890
  lineNo += 1;
964
891
  if (!entry) continue;
965
892
  currentUnit = unitOf(file, lineNo);
@@ -1269,6 +1196,8 @@ console.log(` projects counted: ${countedDirs.length}${countedDirs.length ? ` ($
1269
1196
  console.log(` git side: ${gitSideCommits} own commit(s) read for test-first order and decision records (paths stay here)`);
1270
1197
  console.log(` lines read once: ${duplicateLines} line(s) already counted from another transcript file were skipped${unreadableLines ? ` · ${unreadableLines} line(s) too long to read` : ""}`);
1271
1198
  if (copilotSessionCount > 0) console.log(` copilot chat sessions read: ${copilotSessionCount} (VS Code workspaceStorage, this machine)`);
1199
+ if (!codexLines) console.log(" codex and antigravity: not read (transcript-readers.mjs is not beside this file)");
1200
+ if (antigravityCount > 0) console.log(` antigravity conversations read: ${antigravityCount} (~/.gemini/antigravity*, this machine; clocks and the person's turns, no tokens recorded)`);
1272
1201
  if (codexRolloutCount > 0) console.log(` codex rollouts read: ${codexRolloutCount} (~/.codex/sessions, this machine; tokens once per response, the person's turns only)`);
1273
1202
  if (EXPORTS.length > 0) console.log(` claude.ai export: ${exportConversations} conversation(s) counted${skippedConversations.length ? ` · excluded, never read: ${skippedConversations.length}` : ""}`);
1274
1203
  if (skippedDirs.length > 0) console.log(` excluded, never read: ${skippedDirs.join(", ")}`);