worktrust 0.6.6 → 0.6.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -5
- package/count-behaviour.mjs +26 -97
- package/log-session.mjs +99 -112
- package/package.json +3 -1
- package/setup-mcp.mjs +40 -6
- package/transcript-readers.mjs +217 -0
- package/worktrust.mjs +275 -75
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
|
|
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.
|
|
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
|
|
92
|
-
|
|
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
|
package/count-behaviour.mjs
CHANGED
|
@@ -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
|
-
*
|
|
631
|
-
*
|
|
632
|
-
*
|
|
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
|
|
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(", ")}`);
|