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/README.md +51 -5
- package/config-edits.mjs +69 -0
- package/count-behaviour.mjs +26 -97
- package/log-session.mjs +124 -117
- package/package.json +5 -1
- package/session-databases.mjs +308 -0
- package/setup-mcp.mjs +177 -12
- package/transcript-readers.mjs +217 -0
- package/worktrust.mjs +335 -104
package/README.md
CHANGED
|
@@ -35,9 +35,38 @@ 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.
|
|
48
|
+
7. **Hermes Agent with the other apps.** With Hermes on this computer (`~/.hermes`, and every profile
|
|
49
|
+
under `~/.hermes/profiles`) its `config.yaml` gets the WorkTrust entry under `mcp_servers` and one
|
|
50
|
+
`on_session_end` hook that wakes the session hook; Hermes itself asks once before it runs a new
|
|
51
|
+
hook. A file that writes those sections inline is left as it is. The session hook reads Hermes's
|
|
52
|
+
own `state.db`: clocks, roles, the model and the session's token totals, never a message. A chat
|
|
53
|
+
app (Telegram, WhatsApp, Slack …) is sent as work steered from elsewhere; a subagent's, a
|
|
54
|
+
scheduled job's or a board task's session is not counted. Needs Node 22.5 or newer.
|
|
55
|
+
8. **Goose, OpenCode and OpenClaw the same way.** Goose gets the entry under `extensions` in
|
|
56
|
+
`~/.config/goose/config.yaml` and one plugin folder, `~/.agents/plugins/worktrust`, whose
|
|
57
|
+
SessionEnd hook wakes the session hook. OpenCode gets it under `mcp` in
|
|
58
|
+
`~/.config/opencode/opencode.json` (a file with comments is left as it is) and one plugin file,
|
|
59
|
+
`plugin/worktrust.js`, that wakes the session hook when a session goes idle. OpenClaw is coupled
|
|
60
|
+
through its own `openclaw mcp set` (and `openclaw mcp unset` on disconnect); without that command
|
|
61
|
+
on this computer it is printed for you to run. Each is read from its own session database, the
|
|
62
|
+
same way as Hermes: clocks, roles, models and token counts, never a message. OpenClaw's Talk
|
|
63
|
+
sessions are sent as voice, its chat-app sessions as remote; subagents and scheduled runs are
|
|
64
|
+
not counted.
|
|
38
65
|
|
|
39
66
|
```
|
|
40
67
|
npx worktrust history send this computer's earlier sessions later (asks first)
|
|
68
|
+
npx worktrust codex let Codex wake the session hook (asks first)
|
|
69
|
+
npx worktrust antigravity let Antigravity wake the session hook (asks first)
|
|
41
70
|
npx worktrust status what is coupled here
|
|
42
71
|
npx worktrust disconnect take it out again (shows the plan, asks first)
|
|
43
72
|
```
|
|
@@ -65,9 +94,9 @@ which would delete every password on the computer, and many people rightly keep
|
|
|
65
94
|
|
|
66
95
|
| | |
|
|
67
96
|
|---|---|
|
|
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.
|
|
97
|
+
| **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). 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. |
|
|
98
|
+
| **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/`. |
|
|
99
|
+
| **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
100
|
| **Sends, afterwards** | Durations, token counts, model names, one layer keyword (frontend, backend …), counts of how you work. |
|
|
72
101
|
| **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
102
|
|
|
@@ -86,10 +115,27 @@ which would delete every password on the computer, and many people rightly keep
|
|
|
86
115
|
- **The key** never appears on a command line a process list could show: it is written to its file
|
|
87
116
|
and reaches the bridge and the hook only in their environment.
|
|
88
117
|
|
|
118
|
+
## A computer that is already coupled, or shared
|
|
119
|
+
|
|
120
|
+
- `npx worktrust connect` on a computer that is already coupled says to which account (masked) and
|
|
121
|
+
asks `Couple it again? [y/N]`. Enter and `--yes` mean No; `--replace` says yes in advance.
|
|
122
|
+
- The browser page that approves it shows, large, which WorkTrust account is signed in, with
|
|
123
|
+
**Use another account**: the computer goes to whoever approves it there.
|
|
124
|
+
- Once the new key is in place, the old key is ended at WorkTrust (the request is signed by this
|
|
125
|
+
computer's own device key). An AI app still open on the old key is refused and moves to the new one.
|
|
126
|
+
- When the new account is a different one, the old account sees a notice that its computer moved,
|
|
127
|
+
with the computer's name and the moment, never the other account; no earlier work on this
|
|
128
|
+
computer is offered to the new account, only work from the moment of coupling.
|
|
129
|
+
- `npx worktrust status` shows the account this computer sends to.
|
|
130
|
+
- Different people on one computer with their own OS user each couple their own: everything lives
|
|
131
|
+
in that user's home folder, and the accounts never meet.
|
|
132
|
+
|
|
89
133
|
## Undo
|
|
90
134
|
|
|
91
|
-
`npx worktrust disconnect` removes the WorkTrust entries, the hook and
|
|
92
|
-
|
|
135
|
+
`npx worktrust disconnect` removes the WorkTrust entries (Hermes's hook with them), the hook (and Codex's `notify` line and
|
|
136
|
+
Antigravity's `worktrust` hook when WorkTrust set them) and the key file, and empties
|
|
137
|
+
`~/.worktrust` but for one small note of which account it fed (masked), so that a later coupling by
|
|
138
|
+
another account offers none of this computer's earlier work.
|
|
93
139
|
Then revoke the key in WorkTrust: Sources → Devices → this computer → Revoke.
|
|
94
140
|
|
|
95
141
|
## Requirements
|
package/config-edits.mjs
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CONFIG EDITS — a YAML file edited line by line, never re-serialised (2026-10-05).
|
|
3
|
+
*
|
|
4
|
+
* Hermes Agent and Goose keep their settings in YAML, written by people, with comments and their own indentation.
|
|
5
|
+
* setup-mcp.mjs puts WorkTrust's entry into them and takes it out again with these: a section is found by its key at
|
|
6
|
+
* a known indent, an entry is added at the indent its siblings use, and a removal leaves every other byte as it was.
|
|
7
|
+
* Only block style is handled; a section written inline (`key: { … }`) makes placeUnder return false, and the caller
|
|
8
|
+
* leaves the file alone. No dependency: it ships beside setup-mcp.mjs.
|
|
9
|
+
*/
|
|
10
|
+
const indentOf = (line) => line.length - line.trimStart().length;
|
|
11
|
+
const meaningful = (line) => line.trim() !== "" && !line.trimStart().startsWith("#");
|
|
12
|
+
/** `key:` at `indent` between start and stop: its line and the end of its block, or the inline value it carries. */
|
|
13
|
+
function section(lines, start, stop, indent, key) {
|
|
14
|
+
for (let i = start; i < stop; i += 1) {
|
|
15
|
+
if (!meaningful(lines[i]) || indentOf(lines[i]) !== indent) continue;
|
|
16
|
+
const match = new RegExp(`^\\s*${key}:(?:\\s+(.*?))?\\s*$`).exec(lines[i]);
|
|
17
|
+
if (!match) continue;
|
|
18
|
+
const inline = match[1] && !match[1].startsWith("#") ? match[1] : null;
|
|
19
|
+
let end = i + 1;
|
|
20
|
+
while (end < stop && (!meaningful(lines[end]) || indentOf(lines[end]) > indent || (indentOf(lines[end]) === indent && lines[end].trimStart().startsWith("-")))) end += 1;
|
|
21
|
+
while (end > i + 1 && !meaningful(lines[end - 1])) end -= 1;
|
|
22
|
+
return { at: i, end, inline };
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
/** The entries of a block (a mapping's keys or a list's items), each [from, to). */
|
|
27
|
+
function entries(lines, at, end) {
|
|
28
|
+
const starts = [];
|
|
29
|
+
const first = lines.slice(at + 1, end).find(meaningful);
|
|
30
|
+
const child = first === undefined ? null : indentOf(first);
|
|
31
|
+
for (let i = at + 1; i < end; i += 1) if (meaningful(lines[i]) && indentOf(lines[i]) === child) starts.push(i);
|
|
32
|
+
return { child, ranges: starts.map((from, n) => [from, starts[n + 1] ?? end]) };
|
|
33
|
+
}
|
|
34
|
+
const EMPTY_INLINE = new Set(["{}", "[]", "null", "~"]);
|
|
35
|
+
/** Puts `unit` under the path, making each section that is missing; false when one is written inline. */
|
|
36
|
+
export function placeUnder(lines, path, unit) {
|
|
37
|
+
let start = 0, stop = lines.length, indent = 0;
|
|
38
|
+
for (const [n, key] of path.entries()) {
|
|
39
|
+
let found = section(lines, start, stop, indent, key);
|
|
40
|
+
if (found?.inline && !EMPTY_INLINE.has(found.inline)) return false;
|
|
41
|
+
if (found?.inline) lines[found.at] = `${" ".repeat(indent)}${key}:`;
|
|
42
|
+
if (!found) { lines.splice(stop, 0, `${" ".repeat(indent)}${key}:`); found = { at: stop, end: stop + 1 }; }
|
|
43
|
+
const child = entries(lines, found.at, found.end).child ?? indent + 2;
|
|
44
|
+
if (n === path.length - 1) { lines.splice(found.end, 0, ...unit.map((line) => " ".repeat(child) + line)); return true; }
|
|
45
|
+
start = found.at + 1; stop = found.end; indent = child;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
/** The section at the end of a path, in block style; null when absent or inline. */
|
|
50
|
+
function find(lines, path) {
|
|
51
|
+
let start = 0, stop = lines.length, indent = 0, found = null;
|
|
52
|
+
for (const key of path) {
|
|
53
|
+
found = section(lines, start, stop, indent, key);
|
|
54
|
+
if (!found || found.inline) return null;
|
|
55
|
+
start = found.at + 1; stop = found.end; indent = entries(lines, found.at, found.end).child ?? indent + 2;
|
|
56
|
+
}
|
|
57
|
+
return found;
|
|
58
|
+
}
|
|
59
|
+
/** Takes out every entry under the path that is ours, and a section that leaves empty. */
|
|
60
|
+
export function takeOut(lines, path, mine) {
|
|
61
|
+
const found = find(lines, path);
|
|
62
|
+
if (!found) return;
|
|
63
|
+
let removed = 0;
|
|
64
|
+
for (const [from, to] of entries(lines, found.at, found.end).ranges.reverse()) if (mine(lines.slice(from, to).join("\n"))) { lines.splice(from, to - from); removed += 1; }
|
|
65
|
+
if (removed > 0) for (let depth = path.length; depth > 0; depth -= 1) {
|
|
66
|
+
const left = find(lines, path.slice(0, depth));
|
|
67
|
+
if (left && !lines.slice(left.at + 1, left.end).some(meaningful)) lines.splice(left.at, left.end - left.at);
|
|
68
|
+
}
|
|
69
|
+
}
|
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(", ")}`);
|