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.
@@ -0,0 +1,217 @@
1
+ /**
2
+ * THE TRANSCRIPT READERS — the clients that do not write Claude Code's shape, read into it (2026-10-04).
3
+ *
4
+ * The counter (`count-behaviour.mjs`) and the session hook (`log-session.mjs`) both read other
5
+ * clients' local logs as if they were Claude Code transcripts, so ONE rubric and ONE clock read them
6
+ * all. The readers used to be one block of text copied into both files and held equal by a test;
7
+ * both files had grown past the line ratchet, so the readers now live here, once, and both import
8
+ * this file. It ships beside them: in the npm package, on the site (/counter/transcript-readers.mjs)
9
+ * and in ~/.worktrust, where the hook's install copies it. Without it, both still run and read
10
+ * Claude Code alone, and say so.
11
+ *
12
+ * Text is read here for the rubric and the layer and travels nowhere: what these yield goes to the
13
+ * counter's named rules and the hook's measure(), whose payloads are fixed allowlists of numbers,
14
+ * dates and enum values (log-session.mjs, "WHAT CANNOT LEAVE").
15
+ */
16
+ import { closeSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync, writeFileSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ // ── codex rollout reader ──
22
+ /**
23
+ * A CODEX ROLLOUT, READ AS A TRANSCRIPT (2026-09-27). Codex keeps one JSONL per thread under
24
+ * ~/.codex/sessions/YYYY/MM/DD/rollout-<stamp>-<uuid>.jsonl: a session_meta line, then per turn a
25
+ * turn_context (model, effort, cwd), response_items (messages, tool calls, their outputs) and a
26
+ * token_usage_record per model response. On the machine this was built on five of them held 679
27
+ * million tokens that nothing read. This turns the records into the lines Claude Code writes, so
28
+ * ONE rubric and ONE clock read both:
29
+ * · a user or assistant message → a user or assistant line with a text part; the developer line
30
+ * and the harness's own user-role messages (<environment_context>, <recommended_plugins> and
31
+ * the like: a tag opening the text) are the harness, not the person, and are dropped;
32
+ * · a tool call → an assistant line with a tool_use part: `exec` becomes Bash with the command
33
+ * as input, `apply_patch` an Edit with the first path it names, `spawn_agent` an Agent; its
34
+ * output → a user line with a tool_result, is_error read from Codex's own "exit code N";
35
+ * · a token_usage_record → an assistant line carrying the usage in Claude's keys, ONCE per
36
+ * response_id (Codex writes a response more than once): input minus cached is new input,
37
+ * cached is a cache read, cache_write a cache write, output already holds the reasoning tokens.
38
+ * The running total in event_msg/token_count is never read: it is a counter, not a record.
39
+ * Text is read here for the rubric and the layer and travels nowhere, as with every transcript.
40
+ */
41
+ export const CODEX_ROLLOUT = /(^|\/)rollout-\d{4}-\d{2}-\d{2}T[\d-]+-[0-9a-f-]{36}\.jsonl$/;
42
+ export const CODEX_HARNESS_TURN = /^\s*<[a-z][a-z_]*[\s>]/i;
43
+ 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
+ export const codexToolInput = (name, raw) => {
45
+ const mapped = codexToolName(name);
46
+ if (mapped === "Bash") {
47
+ 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 }; }
48
+ return { command: Array.isArray(raw?.command) ? raw.command.join(" ") : String(raw?.command ?? raw?.cmd ?? "") };
49
+ }
50
+ 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() : "" }; }
51
+ return {};
52
+ };
53
+ export const codexOutputText = (output) => (typeof output === "string" ? output : Array.isArray(output) ? output.map((part) => (typeof part === "string" ? part : part?.text ?? "")).join("\n") : "");
54
+ /** The rollout's raw JSON lines → transcript-shaped line objects, in file order. */
55
+ export function* codexLines(records) {
56
+ let cwd = null, model = null;
57
+ const seenResponses = new Set();
58
+ for (const raw of records) {
59
+ if (!raw || !String(raw).trim()) continue;
60
+ let record; try { record = JSON.parse(raw); } catch { continue; }
61
+ const timestamp = typeof record?.timestamp === "string" ? record.timestamp : null;
62
+ const payload = record?.payload && typeof record.payload === "object" ? record.payload : {};
63
+ if (record.type === "session_meta") { if (typeof payload.cwd === "string") cwd = payload.cwd; continue; }
64
+ if (record.type === "turn_context") { if (typeof payload.cwd === "string") cwd = payload.cwd; if (typeof payload.model === "string") model = payload.model; continue; }
65
+ if (record.type === "token_usage_record") {
66
+ const id = String(payload.response_id ?? "");
67
+ const usage = payload.usage;
68
+ if (!usage || typeof usage !== "object" || (id && seenResponses.has(id))) continue;
69
+ if (id) seenResponses.add(id);
70
+ const n = (key) => (typeof usage[key] === "number" && Number.isFinite(usage[key]) ? usage[key] : 0);
71
+ 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") } } };
72
+ continue;
73
+ }
74
+ if (record.type !== "response_item") continue;
75
+ const kind = payload.type;
76
+ if (kind === "message") {
77
+ if (payload.role !== "user" && payload.role !== "assistant") continue;
78
+ 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 : "";
79
+ if (payload.role === "user" && CODEX_HARNESS_TURN.test(text)) continue;
80
+ yield { type: payload.role, timestamp, cwd, uuid: payload.id ? `codex-${payload.id}` : undefined, message: { ...(payload.role === "assistant" && model ? { model } : {}), content: [{ type: "text", text }] } };
81
+ continue;
82
+ }
83
+ if (kind === "custom_tool_call" || kind === "function_call") {
84
+ const input = kind === "function_call" ? (() => { try { return JSON.parse(payload.arguments ?? "{}"); } catch { return {}; } })() : payload.input;
85
+ 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) }] } };
86
+ continue;
87
+ }
88
+ if (kind === "custom_tool_call_output" || kind === "function_call_output") {
89
+ const text = codexOutputText(payload.output);
90
+ const exit = /(?:failed with|exited with|exit code)[:\s]+(-?\d+)/i.exec(text.slice(-400));
91
+ 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 }] } };
92
+ }
93
+ }
94
+ }
95
+ /** Every rollout under a Codex sessions root, in a stable order. */
96
+ export function* codexRolloutFiles(root) {
97
+ let entries = []; try { entries = readdirSync(root, { withFileTypes: true }); } catch { return; }
98
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
99
+ const full = join(root, entry.name);
100
+ if (entry.isDirectory()) yield* codexRolloutFiles(full);
101
+ else if (CODEX_ROLLOUT.test(entry.name)) yield full;
102
+ }
103
+ }
104
+ /** The rollout's working directory, from its first line alone: the file is not read for it. */
105
+ export function codexCwd(file) {
106
+ let fd; try { fd = openSync(file, "r"); } catch { return null; }
107
+ try {
108
+ const buffer = Buffer.allocUnsafe(64 * 1024);
109
+ const read = readSync(fd, buffer, 0, buffer.length, 0);
110
+ const first = buffer.toString("utf8", 0, read).split("\n")[0] ?? "";
111
+ const meta = JSON.parse(first);
112
+ return meta?.type === "session_meta" && typeof meta.payload?.cwd === "string" ? meta.payload.cwd : null;
113
+ } catch { return null; } finally { closeSync(fd); }
114
+ }
115
+ // ── end codex rollout reader ──
116
+
117
+ // ── antigravity transcript reader ──
118
+ /**
119
+ * A GOOGLE ANTIGRAVITY CONVERSATION, READ AS A TRANSCRIPT (2026-10-04). Antigravity (the 2.0 app,
120
+ * the `agy` CLI and the IDE) keeps one plaintext JSONL per conversation at
121
+ * <app_data_dir>/brain/<conversationId>/.system_generated/logs/transcript.jsonl, app_data_dir being
122
+ * ~/.gemini/antigravity (2.0), ~/.gemini/antigravity-cli (CLI) or, likely, ~/.gemini/antigravity-ide
123
+ * (antigravity.google/docs/hooks/ names the path as the hook's transcriptPath; the step fields from
124
+ * jazzyalex.github.io/agent-sessions/guides/antigravity-cli-local-history.html). One object per
125
+ * step: step_index, source (USER_EXPLICIT | MODEL | SYSTEM), type (USER_INPUT, PLANNER_RESPONSE,
126
+ * RUN_COMMAND, VIEW_FILE, CODE_ACTION …, CONVERSATION_HISTORY, CHECKPOINT, SYSTEM_MESSAGE),
127
+ * status, created_at, content, optional thinking and tool_calls. NO token and NO model field:
128
+ * · a USER_INPUT the person wrote (USER_EXPLICIT) → a user line with a text part: a human turn;
129
+ * · a PLANNER_RESPONSE → an assistant line: its content as text, each tool call a tool_use
130
+ * (a command → Bash, a file write → Edit, a subagent → Agent), the model the HOOK named;
131
+ * · any other step of the loop (a command run, a file viewed) → a user line with a tool_result,
132
+ * which is never a human turn, is_error from its status or the exit phrase;
133
+ * · replayed history, checkpoints, system and ephemeral messages → nothing: the harness, not work.
134
+ * `thinking` is never read. No usage block is made up: such a stretch is sent without tokens.
135
+ * A step written twice (RUNNING, then DONE) is read once, on its step_index.
136
+ */
137
+ export const ANTIGRAVITY_TRANSCRIPT = /(^|\/)brain\/[^/]+\/\.system_generated\/logs\/transcript\.jsonl$/;
138
+ const ANTIGRAVITY_SKIPPED = new Set(["CONVERSATION_HISTORY", "CHECKPOINT", "SYSTEM_MESSAGE", "EPHEMERAL_MESSAGE"]);
139
+ /** 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
+ export const cleanModel = (name) => (typeof name === "string" && /^[A-Za-z0-9][A-Za-z0-9._:/ -]{0,63}$/.test(name.trim()) ? name.trim() : null);
141
+ const antigravityTime = (value) => {
142
+ if (typeof value === "string") { const at = Date.parse(value); return Number.isFinite(at) ? new Date(at).toISOString() : null; }
143
+ if (typeof value === "number" && Number.isFinite(value)) return new Date(value < 1e12 ? value * 1000 : value).toISOString();
144
+ if (value && typeof value === "object" && Number.isFinite(Number(value.seconds))) return new Date(Number(value.seconds) * 1000 + Math.floor(Number(value.nanos ?? 0) / 1e6)).toISOString();
145
+ return null;
146
+ };
147
+ const antigravityArgs = (call) => { const raw = call?.args ?? call?.arguments ?? call?.input ?? {}; if (typeof raw !== "string") return raw && typeof raw === "object" ? raw : {}; try { return JSON.parse(raw) ?? {}; } catch { return {}; } };
148
+ const antigravityTool = (call) => {
149
+ const name = String(call?.name ?? "tool");
150
+ const args = antigravityArgs(call);
151
+ if (/run_command|shell|exec|terminal/i.test(name)) return { type: "tool_use", name: "Bash", input: { command: String(args.CommandLine ?? args.command ?? args.cmd ?? "") } };
152
+ if (/write|replace|edit|code_action|create_file/i.test(name)) return { type: "tool_use", name: "Edit", input: { file_path: String(args.TargetFile ?? args.AbsolutePath ?? args.file_path ?? args.path ?? "") } };
153
+ if (/subagent|spawn|delegate/i.test(name)) return { type: "tool_use", name: "Agent", input: {} };
154
+ return { type: "tool_use", name, input: {} };
155
+ };
156
+ /** The transcript's raw JSON lines → transcript-shaped line objects. `context`: { id, model, cwd } from the hook. */
157
+ export function* antigravityLines(records, context = {}) {
158
+ const model = cleanModel(context.model);
159
+ const cwd = typeof context.cwd === "string" ? context.cwd : null;
160
+ const seen = new Set();
161
+ for (const raw of records) {
162
+ if (!raw || !String(raw).trim()) continue;
163
+ let step; try { step = JSON.parse(raw); } catch { continue; }
164
+ if (!step || typeof step !== "object") continue;
165
+ const index = Number.isInteger(step.step_index) ? step.step_index : null;
166
+ if (index !== null) { if (seen.has(index)) continue; seen.add(index); }
167
+ const timestamp = antigravityTime(step.created_at);
168
+ const type = String(step.type ?? "");
169
+ if (!timestamp || ANTIGRAVITY_SKIPPED.has(type)) continue;
170
+ const uuid = index !== null && context.id ? `antigravity-${context.id}-${index}` : undefined;
171
+ const text = typeof step.content === "string" ? step.content : "";
172
+ if (type === "USER_INPUT") { if (step.source === "USER_EXPLICIT") yield { type: "user", timestamp, cwd, uuid, message: { content: [{ type: "text", text }] } }; continue; }
173
+ if (type === "PLANNER_RESPONSE") {
174
+ // No tool_use id: a result names no call, so the counter pairs a run with the next result in order.
175
+ const calls = (Array.isArray(step.tool_calls) ? step.tool_calls : []).map(antigravityTool);
176
+ yield { type: "assistant", timestamp, cwd, uuid, message: { ...(model ? { model } : {}), content: [...(text ? [{ type: "text", text }] : []), ...calls] } };
177
+ continue;
178
+ }
179
+ const exit = /(?:failed with|exited with|exit code)[:\s]+(-?\d+)/i.exec(text.slice(-400));
180
+ yield { type: "user", timestamp, cwd, uuid, message: { content: [{ type: "tool_result", content: text, is_error: /^(ERROR|FAILED)$/i.test(String(step.status ?? "")) || (exit ? exit[1] !== "0" : false) }] } };
181
+ }
182
+ }
183
+ /** The app data directories Antigravity may write to: the 2.0 app, the CLI, the IDE; a test names its own. */
184
+ export const antigravityRoots = (given) => (given ? [given.replace(/^~(?=\/|$)/, homedir())] : ["antigravity", "antigravity-cli", "antigravity-ide"].map((name) => join(homedir(), ".gemini", name)));
185
+ /** Whether Antigravity is on this machine: its config folder or one of its data folders. */
186
+ export const antigravityHere = () => [join(homedir(), ".gemini", "config"), ...antigravityRoots()].some((path) => { try { return statSync(path).isDirectory(); } catch { return false; } });
187
+ /** Every conversation's transcript under the roots, in a stable order. */
188
+ export function* antigravityTranscripts(roots) {
189
+ for (const root of roots) {
190
+ let ids = []; try { ids = readdirSync(join(root, "brain")).sort(); } catch { continue; }
191
+ for (const id of ids) {
192
+ const file = join(root, "brain", id, ".system_generated", "logs", "transcript.jsonl");
193
+ try { if (statSync(file).isFile()) yield { conversationId: id, file }; } catch { /* no transcript in this conversation */ }
194
+ }
195
+ }
196
+ }
197
+ /**
198
+ * WHAT THE HOOK SAID, KEPT ON THIS MACHINE. The transcript names neither the model nor the folder;
199
+ * the Stop hook's payload does (modelName, workspacePaths). Both are kept in ~/.worktrust/antigravity.json
200
+ * per conversation, so a day swept later still knows its model and its repository; the folder never
201
+ * leaves (the hook reads it for the repository's owner/name and a layer keyword, as for Claude Code).
202
+ */
203
+ const MEMORY = () => join(homedir(), ".worktrust", "antigravity.json");
204
+ const readMemory = () => { try { return JSON.parse(readFileSync(MEMORY(), "utf8")) ?? {}; } catch { return {}; } };
205
+ export const antigravityContext = (conversationId) => readMemory()[conversationId] ?? {};
206
+ export function rememberAntigravity(input) {
207
+ const id = typeof input?.conversationId === "string" && /^[A-Za-z0-9_-]{1,80}$/.test(input.conversationId) ? input.conversationId : null;
208
+ if (!id) return null;
209
+ const first = Array.isArray(input.workspacePaths) ? input.workspacePaths.find((path) => typeof path === "string") : null;
210
+ let cwd = null; try { cwd = first ? (first.startsWith("file://") ? fileURLToPath(first) : first) : null; } catch { cwd = null; }
211
+ const memory = readMemory();
212
+ memory[id] = { ...(memory[id] ?? {}), ...(cleanModel(input.modelName) ? { model: cleanModel(input.modelName) } : {}), ...(cwd ? { cwd } : {}), at: new Date().toISOString() };
213
+ const kept = Object.fromEntries(Object.entries(memory).sort((a, b) => String(b[1]?.at ?? "").localeCompare(String(a[1]?.at ?? ""))).slice(0, 500));
214
+ try { mkdirSync(join(homedir(), ".worktrust"), { recursive: true }); writeFileSync(MEMORY(), JSON.stringify(kept)); } catch { /* the sweep still runs, without the model */ }
215
+ return id;
216
+ }
217
+ // ── end antigravity transcript reader ──