worktrust 0.0.0-stage → 0.5.0
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/LICENSE +21 -0
- package/README.md +99 -2
- package/SECURITY.md +42 -0
- package/count-behaviour.mjs +1430 -0
- package/log-session.mjs +858 -0
- package/package.json +41 -4
- package/setup-mcp.mjs +140 -0
- package/worktrust.mjs +470 -0
package/log-session.mjs
ADDED
|
@@ -0,0 +1,858 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* THE MOMENT OF THE WORK, RECORDED — a Claude Code hook that logs a finished stretch.
|
|
4
|
+
*
|
|
5
|
+
* The record already holds the commits: 3,760 of them on the first record this was built for,
|
|
6
|
+
* with green CI on a third. What it did not hold was that a person and an agent were there while
|
|
7
|
+
* they were made, so a session carried an artefact change and a validation and no AI interaction
|
|
8
|
+
* — and a work unit needs all three. Nine units on seventeen months of work, and the missing half
|
|
9
|
+
* was not judgement. It was presence.
|
|
10
|
+
*
|
|
11
|
+
* IT NEVER LOSES A MEASURED DAY. A session is reported day by day, on whichever run comes first,
|
|
12
|
+
* so a session that stays open for a fortnight is not a fortnight of silence; a machine that was
|
|
13
|
+
* closed for four months hands over its months when it comes back, through the history door; and
|
|
14
|
+
* a day the network refused is sent again on the next run, because the watermark advances over
|
|
15
|
+
* what was accepted and stops at the first refusal.
|
|
16
|
+
*
|
|
17
|
+
* This hook supplies exactly that half, and nothing else. It NEVER decides how good the work was:
|
|
18
|
+
* scoring calls no model, and a stretch reported here is corroborated by the commit and the CI run
|
|
19
|
+
* that land beside it or it counts for nothing. What it sends is what a clock and a token counter
|
|
20
|
+
* recorded.
|
|
21
|
+
*
|
|
22
|
+
* node log-session.mjs --install wire it into Claude Code (SessionEnd, SessionStart, PreCompact)
|
|
23
|
+
* node log-session.mjs --uninstall [--purge] take it out again (--purge also deletes the copies)
|
|
24
|
+
* node log-session.mjs --dry-run show what the current transcripts would send
|
|
25
|
+
* node log-session.mjs --history [--dry-run] send the months already on this machine, as HISTORY
|
|
26
|
+
* (as a hook) reads the hook's JSON on stdin, logs, exits
|
|
27
|
+
* (from Codex) notify = ["node", "~/.worktrust/log-session.mjs", "--codex-notify"]
|
|
28
|
+
* in ~/.codex/config.toml: Codex calls it at every turn's end and
|
|
29
|
+
* the sweep below finds the thread's rollout; its argument, which
|
|
30
|
+
* carries text, is never read
|
|
31
|
+
*
|
|
32
|
+
* CODEX ROLLOUTS (2026-09-27) are swept beside the Claude Code transcripts: ~/.codex/sessions holds
|
|
33
|
+
* the same clocks, the model per turn and a token record per response, and the reader below turns
|
|
34
|
+
* them into the lines this script already measures. One hook, two clients, one rule for both.
|
|
35
|
+
*
|
|
36
|
+
* WHAT CANNOT LEAVE. The payload is built from a fixed allowlist of keys, all of them numbers,
|
|
37
|
+
* dates or vocabulary from the door's own enums. No prompt, no answer, no file path, no commit
|
|
38
|
+
* message, no branch, no project name. File paths ARE read locally to decide one layer keyword,
|
|
39
|
+
* the way the connector reads them to decide an activity class, and only the keyword travels.
|
|
40
|
+
*
|
|
41
|
+
* WHAT IS MEASURED, AND WHAT IS THEREFORE NOT SENT. A stretch's seconds are the sum of the gaps
|
|
42
|
+
* between consecutive messages in the transcript, each gap counted only up to IDLE_CAP: two
|
|
43
|
+
* recorded timestamps at both ends, and a pause longer than the cap is not work. That is what
|
|
44
|
+
* `duration_basis: "measured"` means and it is the only basis this script ever sends — a stretch
|
|
45
|
+
* whose transcript carries no timestamps is skipped rather than estimated. Tokens are what each
|
|
46
|
+
* request ADDED (input plus cache creation, cache reads excluded), which the door calls
|
|
47
|
+
* `newInput`.
|
|
48
|
+
*
|
|
49
|
+
* CONFIDENTIAL PROJECTS never open: `~/.worktrust-counter.json` { "exclude": ["client-x"] } is
|
|
50
|
+
* honoured here too, matched against the transcript's project directory, before a file is read.
|
|
51
|
+
*/
|
|
52
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
53
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
54
|
+
import { homedir } from "node:os";
|
|
55
|
+
import { dirname, join } from "node:path";
|
|
56
|
+
import { createHash, randomBytes, sign as cryptoSign } from "node:crypto";
|
|
57
|
+
|
|
58
|
+
const args = process.argv.slice(2);
|
|
59
|
+
const flag = (name) => args.includes(`--${name}`);
|
|
60
|
+
const value = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : undefined; };
|
|
61
|
+
const DRY = flag("dry-run");
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* THE OTHER HALF OF A SESSION: HOW IT WAS ASKED.
|
|
65
|
+
*
|
|
66
|
+
* This hook records that somebody was there. What KIND of work it was — the questions asked, the
|
|
67
|
+
* assumptions surfaced, the counter-arguments, the verification requests — is the local counter's
|
|
68
|
+
* job (`count-behaviour.mjs`, a named rubric over the same transcripts), and it was a thing a
|
|
69
|
+
* person had to remember to run. Now the hook runs it, once a day, in STAGING mode: the reading
|
|
70
|
+
* waits in the app until the owner clicks Import. That is not politeness, it is F-04 — the token
|
|
71
|
+
* may stage, only the owner may count — and a hook that could send counts by itself would put an
|
|
72
|
+
* automatic writer on the one surface that describes how a person thinks.
|
|
73
|
+
*/
|
|
74
|
+
const COUNTER = join(homedir(), ".worktrust", "count-behaviour.mjs");
|
|
75
|
+
/** PINNED (2026-10-03): from the npm package, the counter is the package's copy, never refreshed from the network. */
|
|
76
|
+
const PINNED = join(homedir(), ".worktrust", "pinned");
|
|
77
|
+
/** A hook command is ours when it runs this file, or the `worktrust hook` wrapper that hands it the key from the key file. */
|
|
78
|
+
const ours = (command) => /log-session|worktrust\.mjs" hook|worktrust\.mjs hook/.test(String(command ?? ""));
|
|
79
|
+
const COUNTER_EVERY_MS = 24 * 3600_000;
|
|
80
|
+
/**
|
|
81
|
+
* THE COUNTER KEEPS UP WITH THE DEPLOYMENT. The install fetched it once and never again, so a
|
|
82
|
+
* machine set up in August counted with an August rubric in September while the door already
|
|
83
|
+
* spoke a newer vocabulary. Now the version stamped in the local copy is compared with the one the
|
|
84
|
+
* deployment serves — numerically, the way the door picks its newest rubric — and a newer copy
|
|
85
|
+
* replaces the old one. A rubric bump is what makes the previous reading superseded on the
|
|
86
|
+
* mirror; this is how the machine hears about it. Nothing else is read off the network here.
|
|
87
|
+
*/
|
|
88
|
+
const versionOf = (text) => /export const ANALYZER_VERSION = "counter@(\d+)\.(\d+)\.(\d+)"/.exec(text ?? "")?.slice(1, 4).map(Number) ?? null;
|
|
89
|
+
const newer = (a, b) => { if (!a || !b) return false; for (let i = 0; i < 3; i += 1) { if (a[i] === b[i]) continue; return a[i] > b[i]; } return false; };
|
|
90
|
+
async function refreshCounter(origin, announce) {
|
|
91
|
+
if (!origin || !existsSync(COUNTER) || existsSync(PINNED)) return false;
|
|
92
|
+
try {
|
|
93
|
+
const response = await fetch(`${origin}/counter/count-behaviour.mjs`);
|
|
94
|
+
if (!response.ok) return false;
|
|
95
|
+
const remote = await response.text();
|
|
96
|
+
const have = versionOf(readFileSync(COUNTER, "utf8")), theirs = versionOf(remote);
|
|
97
|
+
if (!newer(theirs, have)) return false;
|
|
98
|
+
writeFileSync(COUNTER, remote);
|
|
99
|
+
announce?.(`counter: updated to counter@${theirs.join(".")} (was counter@${have?.join(".") ?? "?"}) — the next reading counts under the new rubric`);
|
|
100
|
+
return true;
|
|
101
|
+
} catch { return false; }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
/** A pause longer than this is not work, so it is not counted. Five minutes. */
|
|
106
|
+
const IDLE_CAP = 300;
|
|
107
|
+
/**
|
|
108
|
+
* The live door refuses a stretch older than two days, because it records work AS IT HAPPENS —
|
|
109
|
+
* that rule is what stops a year of invented afternoons walking in through the same door.
|
|
110
|
+
*
|
|
111
|
+
* It is not a reason to lose the work. Past this line a measured day is filed through
|
|
112
|
+
* `import_history` instead: the same counted seconds, marked as history, standing as history
|
|
113
|
+
* stands (it corroborates, and it never lifts a level on its own). Late is not the same as untrue.
|
|
114
|
+
*/
|
|
115
|
+
const WINDOW_HOURS = 48;
|
|
116
|
+
/** A stretch still being worked in is not finished; half an hour of quiet says it is. */
|
|
117
|
+
const IDLE_BEFORE_SWEEP = 30 * 60 * 1000;
|
|
118
|
+
/** Under a minute is not a piece of work. */
|
|
119
|
+
const MIN_SECONDS = 60;
|
|
120
|
+
/**
|
|
121
|
+
* The door's own ceiling for one stretch. A day past it is reported SHORT: under-reporting is the
|
|
122
|
+
* safe direction. Stretches are split by DAY, which is a boundary the clock draws — never by
|
|
123
|
+
* guessing where one piece of work ended and the next began.
|
|
124
|
+
*/
|
|
125
|
+
const MAX_SECONDS = 28800;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* WHEN THE HOOK RUNS. SessionEnd alone was a bet that sessions end, and a session that stays open
|
|
129
|
+
* for a fortnight — resumed through every compaction — reported nothing for a fortnight. These
|
|
130
|
+
* three are the moments Claude Code offers that come round on their own: a session closing, a
|
|
131
|
+
* session opening, and a compaction, which on a long session happens several times a day.
|
|
132
|
+
*
|
|
133
|
+
* Running more often costs nothing extra: the watermark means a run with nothing new to say opens
|
|
134
|
+
* no file, and a day still being worked in is never sent twice.
|
|
135
|
+
*/
|
|
136
|
+
const HOOK_EVENTS = ["SessionEnd", "SessionStart", "PreCompact"];
|
|
137
|
+
|
|
138
|
+
const STATE = join(homedir(), ".worktrust", "sessions.json");
|
|
139
|
+
const readState = () => { try { return JSON.parse(readFileSync(STATE, "utf8")); } catch { return {}; } };
|
|
140
|
+
const writeState = (state) => { mkdirSync(dirname(STATE), { recursive: true }); writeFileSync(STATE, JSON.stringify(state, null, 1)); };
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* ONLY THE HOUSE'S OWN DOOR (2026-09-27, audit): the coupling used to be "any MCP server whose URL
|
|
144
|
+
* contains /api/mcp", the conventional path of every hosted MCP server, so a person who also held
|
|
145
|
+
* another product's server would have sent their work metadata there under that product's token and
|
|
146
|
+
* fetched a script to run from its origin. A door is WorkTrust's when its host is worktrust.io or a
|
|
147
|
+
* subdomain, or a local one named by WORKTRUST_MCP_URL; nothing else is a coupling.
|
|
148
|
+
*/
|
|
149
|
+
const isWorkTrustDoor = (url) => {
|
|
150
|
+
try {
|
|
151
|
+
const parsed = new URL(String(url));
|
|
152
|
+
if (!/\/api\/mcp\/?$/.test(parsed.pathname)) return false;
|
|
153
|
+
const own = process.env.WORKTRUST_MCP_URL ? new URL(process.env.WORKTRUST_MCP_URL).host : null;
|
|
154
|
+
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";
|
|
155
|
+
} catch { return false; }
|
|
156
|
+
};
|
|
157
|
+
/** The coupling: given, or the one Claude Code already holds — the same read the counter does. */
|
|
158
|
+
function coupling() {
|
|
159
|
+
const url = value("url") ?? process.env.WORKTRUST_MCP_URL;
|
|
160
|
+
const token = value("token") ?? process.env.WORKTRUST_MCP_TOKEN;
|
|
161
|
+
if (url && token) return { url, token };
|
|
162
|
+
try {
|
|
163
|
+
const config = JSON.parse(readFileSync(join(homedir(), ".claude.json"), "utf8"));
|
|
164
|
+
const found = [];
|
|
165
|
+
const walk = (node) => {
|
|
166
|
+
if (!node || typeof node !== "object") return;
|
|
167
|
+
if (typeof node.url === "string" && isWorkTrustDoor(node.url)) {
|
|
168
|
+
const auth = node.headers?.Authorization ?? node.headers?.authorization ?? "";
|
|
169
|
+
if (auth.startsWith("Bearer ")) found.push({ url: node.url, token: auth.slice(7) });
|
|
170
|
+
}
|
|
171
|
+
for (const child of Object.values(node)) walk(child);
|
|
172
|
+
};
|
|
173
|
+
walk(config);
|
|
174
|
+
if (found[0]) return found[0];
|
|
175
|
+
} catch { /* no Claude Code config on this machine; Codex may hold the coupling */ }
|
|
176
|
+
return codexCoupling();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const excludes = (() => {
|
|
180
|
+
try { return (JSON.parse(readFileSync(join(homedir(), ".worktrust-counter.json"), "utf8")).exclude ?? []).map(String); } catch { return []; }
|
|
181
|
+
})();
|
|
182
|
+
const excluded = (path) => excludes.some((text) => text && path.includes(text));
|
|
183
|
+
// ── codex rollout reader (the same text in count-behaviour.mjs and log-session.mjs; test-codex-rollout holds them equal) ──
|
|
184
|
+
/**
|
|
185
|
+
* A CODEX ROLLOUT, READ AS A TRANSCRIPT (2026-09-27). Codex keeps one JSONL per thread under
|
|
186
|
+
* ~/.codex/sessions/YYYY/MM/DD/rollout-<stamp>-<uuid>.jsonl: a session_meta line, then per turn a
|
|
187
|
+
* turn_context (model, effort, cwd), response_items (messages, tool calls, their outputs) and a
|
|
188
|
+
* token_usage_record per model response. On the machine this was built on five of them held 679
|
|
189
|
+
* million tokens that nothing read. This turns the records into the lines Claude Code writes, so
|
|
190
|
+
* ONE rubric and ONE clock read both:
|
|
191
|
+
* · a user or assistant message → a user or assistant line with a text part; the developer line
|
|
192
|
+
* and the harness's own user-role messages (<environment_context>, <recommended_plugins> and
|
|
193
|
+
* the like: a tag opening the text) are the harness, not the person, and are dropped;
|
|
194
|
+
* · a tool call → an assistant line with a tool_use part: `exec` becomes Bash with the command
|
|
195
|
+
* as input, `apply_patch` an Edit with the first path it names, `spawn_agent` an Agent; its
|
|
196
|
+
* output → a user line with a tool_result, is_error read from Codex's own "exit code N";
|
|
197
|
+
* · a token_usage_record → an assistant line carrying the usage in Claude's keys, ONCE per
|
|
198
|
+
* response_id (Codex writes a response more than once): input minus cached is new input,
|
|
199
|
+
* cached is a cache read, cache_write a cache write, output already holds the reasoning tokens.
|
|
200
|
+
* The running total in event_msg/token_count is never read: it is a counter, not a record.
|
|
201
|
+
* Text is read here for the rubric and the layer and travels nowhere, as with every transcript.
|
|
202
|
+
*/
|
|
203
|
+
const CODEX_ROLLOUT = /(^|\/)rollout-\d{4}-\d{2}-\d{2}T[\d-]+-[0-9a-f-]{36}\.jsonl$/;
|
|
204
|
+
const CODEX_HARNESS_TURN = /^\s*<[a-z][a-z_]*[\s>]/i;
|
|
205
|
+
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"));
|
|
206
|
+
const codexToolInput = (name, raw) => {
|
|
207
|
+
const mapped = codexToolName(name);
|
|
208
|
+
if (mapped === "Bash") {
|
|
209
|
+
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 }; }
|
|
210
|
+
return { command: Array.isArray(raw?.command) ? raw.command.join(" ") : String(raw?.command ?? raw?.cmd ?? "") };
|
|
211
|
+
}
|
|
212
|
+
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() : "" }; }
|
|
213
|
+
return {};
|
|
214
|
+
};
|
|
215
|
+
const codexOutputText = (output) => (typeof output === "string" ? output : Array.isArray(output) ? output.map((part) => (typeof part === "string" ? part : part?.text ?? "")).join("\n") : "");
|
|
216
|
+
/** The rollout's raw JSON lines → transcript-shaped line objects, in file order. */
|
|
217
|
+
function* codexLines(records) {
|
|
218
|
+
let cwd = null, model = null;
|
|
219
|
+
const seenResponses = new Set();
|
|
220
|
+
for (const raw of records) {
|
|
221
|
+
if (!raw || !String(raw).trim()) continue;
|
|
222
|
+
let record; try { record = JSON.parse(raw); } catch { continue; }
|
|
223
|
+
const timestamp = typeof record?.timestamp === "string" ? record.timestamp : null;
|
|
224
|
+
const payload = record?.payload && typeof record.payload === "object" ? record.payload : {};
|
|
225
|
+
if (record.type === "session_meta") { if (typeof payload.cwd === "string") cwd = payload.cwd; continue; }
|
|
226
|
+
if (record.type === "turn_context") { if (typeof payload.cwd === "string") cwd = payload.cwd; if (typeof payload.model === "string") model = payload.model; continue; }
|
|
227
|
+
if (record.type === "token_usage_record") {
|
|
228
|
+
const id = String(payload.response_id ?? "");
|
|
229
|
+
const usage = payload.usage;
|
|
230
|
+
if (!usage || typeof usage !== "object" || (id && seenResponses.has(id))) continue;
|
|
231
|
+
if (id) seenResponses.add(id);
|
|
232
|
+
const n = (key) => (typeof usage[key] === "number" && Number.isFinite(usage[key]) ? usage[key] : 0);
|
|
233
|
+
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") } } };
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (record.type !== "response_item") continue;
|
|
237
|
+
const kind = payload.type;
|
|
238
|
+
if (kind === "message") {
|
|
239
|
+
if (payload.role !== "user" && payload.role !== "assistant") continue;
|
|
240
|
+
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 : "";
|
|
241
|
+
if (payload.role === "user" && CODEX_HARNESS_TURN.test(text)) continue;
|
|
242
|
+
yield { type: payload.role, timestamp, cwd, uuid: payload.id ? `codex-${payload.id}` : undefined, message: { ...(payload.role === "assistant" && model ? { model } : {}), content: [{ type: "text", text }] } };
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (kind === "custom_tool_call" || kind === "function_call") {
|
|
246
|
+
const input = kind === "function_call" ? (() => { try { return JSON.parse(payload.arguments ?? "{}"); } catch { return {}; } })() : payload.input;
|
|
247
|
+
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) }] } };
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (kind === "custom_tool_call_output" || kind === "function_call_output") {
|
|
251
|
+
const text = codexOutputText(payload.output);
|
|
252
|
+
const exit = /(?:failed with|exited with|exit code)[:\s]+(-?\d+)/i.exec(text.slice(-400));
|
|
253
|
+
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 }] } };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/** Every rollout under a Codex sessions root, in a stable order. */
|
|
258
|
+
function* codexRolloutFiles(root) {
|
|
259
|
+
let entries = []; try { entries = readdirSync(root, { withFileTypes: true }); } catch { return; }
|
|
260
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
261
|
+
const full = join(root, entry.name);
|
|
262
|
+
if (entry.isDirectory()) yield* codexRolloutFiles(full);
|
|
263
|
+
else if (CODEX_ROLLOUT.test(entry.name)) yield full;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/** The rollout's working directory, from its first line alone: the file is not read for it. */
|
|
267
|
+
function codexCwd(file) {
|
|
268
|
+
let fd; try { fd = openSync(file, "r"); } catch { return null; }
|
|
269
|
+
try {
|
|
270
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
271
|
+
const read = readSync(fd, buffer, 0, buffer.length, 0);
|
|
272
|
+
const first = buffer.toString("utf8", 0, read).split("\n")[0] ?? "";
|
|
273
|
+
const meta = JSON.parse(first);
|
|
274
|
+
return meta?.type === "session_meta" && typeof meta.payload?.cwd === "string" ? meta.payload.cwd : null;
|
|
275
|
+
} catch { return null; } finally { closeSync(fd); }
|
|
276
|
+
}
|
|
277
|
+
// ── end codex rollout reader ──
|
|
278
|
+
|
|
279
|
+
/** The roots this machine's transcripts live under; a test names its own. */
|
|
280
|
+
const claudeRoot = () => value("transcript-root") ?? join(homedir(), ".claude", "projects");
|
|
281
|
+
const codexRoot = () => { const given = value("codex-root"); if (given) return given.replace(/^~(?=\/|$)/, homedir()); return value("transcript-root") ? null : join(homedir(), ".codex", "sessions"); };
|
|
282
|
+
/** Every Codex rollout on this machine, an excluded project's threads skipped by their working directory. */
|
|
283
|
+
function* codexRollouts() {
|
|
284
|
+
const root = codexRoot();
|
|
285
|
+
if (!root) return;
|
|
286
|
+
for (const file of codexRolloutFiles(root)) {
|
|
287
|
+
const cwd = codexCwd(file);
|
|
288
|
+
if (cwd && excluded(cwd)) continue;
|
|
289
|
+
yield { id: `codex:${file.split("/").at(-1).replace(/\.jsonl$/, "")}`, file };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/** A transcript's lines as objects, whichever client wrote it; null when the file cannot be read. */
|
|
293
|
+
const parsedLines = (file) => {
|
|
294
|
+
let lines; try { lines = readFileSync(file, "utf8").split("\n"); } catch { return null; }
|
|
295
|
+
if (CODEX_ROLLOUT.test(file)) return [...codexLines(lines)];
|
|
296
|
+
const out = [];
|
|
297
|
+
for (const raw of lines) { if (!raw.trim()) continue; try { out.push(JSON.parse(raw)); } catch { /* a torn line */ } }
|
|
298
|
+
return out;
|
|
299
|
+
};
|
|
300
|
+
/** The coupling Codex holds, when Claude Code holds none: the same door, read from ~/.codex/config.toml. */
|
|
301
|
+
function codexCoupling() {
|
|
302
|
+
let toml; try { toml = readFileSync(join(homedir(), ".codex", "config.toml"), "utf8"); } catch { return null; }
|
|
303
|
+
for (const block of toml.split(/\n(?=\[)/)) {
|
|
304
|
+
if (!/^\[mcp_servers\./.test(block)) continue;
|
|
305
|
+
const url = /^\s*url\s*=\s*"([^"]+)"/m.exec(block)?.[1];
|
|
306
|
+
const token = /Bearer\s+([A-Za-z0-9_.-]+)/.exec(block)?.[1];
|
|
307
|
+
if (url && isWorkTrustDoor(url) && token) return { url, token };
|
|
308
|
+
}
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
/** Which layer a set of paths touched — the same plain reading the connector uses for classes. */
|
|
314
|
+
const LAYER_RULES = [
|
|
315
|
+
["database", /(^|\/)migrations?\/|\.sql$|(^|\/)(schema|seed)s?\.(prisma|sql|ts|rb)$/i],
|
|
316
|
+
["integration", /(^|\/)(integrations?|connectors?|webhooks?|clients?|sdk)\/|\.(proto|graphql|gql)$|(^|\/)openapi\.(yaml|yml|json)$/i],
|
|
317
|
+
["tooling", /(^|\/)\.github\/|(^|\/)(infra|terraform|k8s|helm|deploy|scripts?)\/|\.(tf|tfvars)$|(^|\/)(Dockerfile|docker-compose[^/]*|Makefile)$|(^|\/)\.claude\//i],
|
|
318
|
+
["frontend", /\.(css|scss|less|html|vue|svelte|astro|jsx|tsx)$/i],
|
|
319
|
+
["backend", /\.(py|go|rs|java|kt|rb|php|cs|ex|exs|swift|c|cc|cpp|h|hpp)$|(^|\/)(api|server|lib)\//i],
|
|
320
|
+
["docs", /\.(md|mdx|rst|adoc)$|(^|\/)docs?\//i],
|
|
321
|
+
];
|
|
322
|
+
const layersOf = (paths) => {
|
|
323
|
+
const tally = new Map();
|
|
324
|
+
for (const path of paths) for (const [layer, rule] of LAYER_RULES) if (rule.test(path)) { tally.set(layer, (tally.get(layer) ?? 0) + 1); break; }
|
|
325
|
+
// MOST FILES FIRST, BUT ALL OF THEM. One stretch through the front end, the back end and the
|
|
326
|
+
// docs is one piece of work with one duration and three layers; reporting only the winner threw
|
|
327
|
+
// two thirds of what it touched away, and a layer is what a dimension is attributed by.
|
|
328
|
+
const ordered = [...tally.entries()].sort((a, b) => b[1] - a[1]).map(([layer]) => layer);
|
|
329
|
+
return { layer: ordered[0] ?? null, layers: ordered };
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const git = (cwd, ...argv) => { try { return execFileSync("git", ["-C", cwd, ...argv], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { return ""; } };
|
|
333
|
+
/** owner/name from any remote shape, or null. Nothing else of the remote is read. */
|
|
334
|
+
const repoOf = (cwd) => {
|
|
335
|
+
const remote = git(cwd, "remote", "get-url", "origin");
|
|
336
|
+
const match = /[:/]([^/:]+)\/([^/]+?)(?:\.git)?$/.exec(remote);
|
|
337
|
+
return match ? `${match[1]}/${match[2]}` : null;
|
|
338
|
+
};
|
|
339
|
+
/** The paths this stretch touched: what was committed inside it, else what is still uncommitted. */
|
|
340
|
+
const pathsTouched = (cwd, from, to) => {
|
|
341
|
+
const committed = git(cwd, "log", `--since=${from}`, `--until=${to}`, "--name-only", "--pretty=format:", "--no-merges");
|
|
342
|
+
const paths = committed.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
343
|
+
// The commits themselves, as shas — a hash and nothing of the message. They let the record
|
|
344
|
+
// join this stretch to its own artefacts (roadmap R8); the paths above never travel.
|
|
345
|
+
const shas = git(cwd, "log", `--since=${from}`, `--until=${to}`, "--format=%H", "--no-merges").split("\n").map((line) => line.trim()).filter((line) => /^[0-9a-f]{40}$/.test(line));
|
|
346
|
+
if (paths.length > 0) return { paths, committed: true, shas };
|
|
347
|
+
const dirty = git(cwd, "status", "--porcelain").split("\n").map((line) => line.slice(3).trim()).filter(Boolean);
|
|
348
|
+
return { paths: dirty, committed: false, shas };
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* One transcript → the unlogged stretches, measured, ONE PER DAY.
|
|
353
|
+
*
|
|
354
|
+
* It used to return a single stretch covering everything since the watermark, which was right for
|
|
355
|
+
* a session that lasts an afternoon and wrong in three ways for one that lasts a fortnight: the
|
|
356
|
+
* whole fortnight arrived as one record dated at its end, it was truncated at the door's
|
|
357
|
+
* per-stretch ceiling, and none of it existed until the session finally closed. A day is the
|
|
358
|
+
* boundary the history path already uses, it needs no guess about where one piece of work ended,
|
|
359
|
+
* and it is the unit the record reads back anyway.
|
|
360
|
+
*/
|
|
361
|
+
function stretchesOf(file, since) {
|
|
362
|
+
// A file we cannot read is no days, never a null the caller has to remember to check: this
|
|
363
|
+
// returns a list now, and one caller forgetting that is one silent crash inside a hook.
|
|
364
|
+
const lines = parsedLines(file);
|
|
365
|
+
if (!lines) return [];
|
|
366
|
+
const messages = [];
|
|
367
|
+
let cwd = null;
|
|
368
|
+
for (const line of lines) {
|
|
369
|
+
if (typeof line.cwd === "string") cwd = line.cwd;
|
|
370
|
+
if (!line.timestamp || (line.type !== "user" && line.type !== "assistant")) continue;
|
|
371
|
+
const at = Date.parse(line.timestamp);
|
|
372
|
+
if (!Number.isFinite(at)) continue;
|
|
373
|
+
if (since && at <= since) continue;
|
|
374
|
+
messages.push({ at, type: line.type, usage: line.message?.usage ?? null, model: line.message?.model ?? null, meta: line.isMeta === true, content: line.message?.content ?? null });
|
|
375
|
+
}
|
|
376
|
+
if (messages.length === 0) return [];
|
|
377
|
+
messages.sort((a, b) => a.at - b.at);
|
|
378
|
+
const byDay = new Map();
|
|
379
|
+
for (const message of messages) {
|
|
380
|
+
const day = new Date(message.at).toISOString().slice(0, 10);
|
|
381
|
+
if (!byDay.has(day)) byDay.set(day, []);
|
|
382
|
+
byDay.get(day).push(message);
|
|
383
|
+
}
|
|
384
|
+
return [...byDay.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, ofDay]) => measure(cwd, ofDay)).filter(Boolean);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* IS THIS A HUMAN TURN? (2026-09-20)
|
|
389
|
+
*
|
|
390
|
+
* It used to be "every user-role message that is not meta", and in agentic work that is
|
|
391
|
+
* overwhelmingly the AGENT's own tool results coming back — they arrive on the user role, because
|
|
392
|
+
* that is how the protocol carries them. Measured on one real session: 959 counted, 910 of them
|
|
393
|
+
* tool results, 49 actual prompts. A 19.6x inflation, and it grows with how autonomous the work is,
|
|
394
|
+
* so the number moved the WRONG WAY exactly where it mattered: more agent loop read as more human
|
|
395
|
+
* turns. The door's own field description has always said "one turn is one message from the
|
|
396
|
+
* person"; this makes the hook agree with it.
|
|
397
|
+
*
|
|
398
|
+
* A human turn carries text (a string, or blocks with `text`/`image`) and NO `tool_result` block.
|
|
399
|
+
* A message carrying both is a tool result with commentary attached, which is still the loop.
|
|
400
|
+
*/
|
|
401
|
+
function isHumanTurn(content) {
|
|
402
|
+
if (typeof content === "string") return content.trim().length > 0;
|
|
403
|
+
if (!Array.isArray(content)) return false;
|
|
404
|
+
const types = new Set(content.map((block) => block && block.type));
|
|
405
|
+
if (types.has("tool_result")) return false;
|
|
406
|
+
return types.has("text") || types.has("image");
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** One day's messages → the stretch, measured. Null when there is nothing honest to send. */
|
|
410
|
+
function measure(cwd, messages) {
|
|
411
|
+
// MEASURED, AND ONLY MEASURED: every second counted lies between two recorded timestamps, and a
|
|
412
|
+
// gap longer than the cap is a pause rather than work. No stretch is ever inferred from a count.
|
|
413
|
+
let seconds = 0;
|
|
414
|
+
for (let i = 1; i < messages.length; i += 1) seconds += Math.min(IDLE_CAP, Math.round((messages[i].at - messages[i - 1].at) / 1000));
|
|
415
|
+
// A DURATION MAY BE ABSENT; THE STRETCH MAY NOT. What corroborates a commit is that a person and
|
|
416
|
+
// an agent were there at that moment, not how long they stayed — so a stretch too short to time,
|
|
417
|
+
// or one whose clocks give nothing, is still reported, WITHOUT seconds and without a basis. The
|
|
418
|
+
// alternative is a hook that goes quiet exactly where the record is thinnest, or one that fills
|
|
419
|
+
// the gap with a guess: the same fabrication in a smaller jacket.
|
|
420
|
+
const measured = seconds >= MIN_SECONDS ? Math.min(MAX_SECONDS, seconds) : null;
|
|
421
|
+
const models = new Map();
|
|
422
|
+
let exchanges = 0;
|
|
423
|
+
for (const message of messages) {
|
|
424
|
+
if (message.type === "user" && !message.meta && isHumanTurn(message.content)) exchanges += 1;
|
|
425
|
+
if (message.model) models.set(message.model, (models.get(message.model) ?? 0) + 1);
|
|
426
|
+
}
|
|
427
|
+
const model = [...models.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
|
|
428
|
+
const tokens = tokensOf(messages);
|
|
429
|
+
return { cwd, seconds: measured, ...tokens, exchanges, model, from: messages[0].at, to: messages[messages.length - 1].at };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* THE TOKENS, AND THE CACHE HALVES APART (roadmap 16). Claude Code's usage block carries input_tokens, output_tokens,
|
|
434
|
+
* cache_creation_input_tokens and cache_read_input_tokens. `tokensIn` stays what it was — new input plus what was written
|
|
435
|
+
* into the cache (the `newInput` basis) — and the two cache halves travel beside it as their own counts, so the mirror can
|
|
436
|
+
* read a hit ratio. A usage block of a SHAPE this script does not know (a key that is present and not a number, or a block
|
|
437
|
+
* without input and output) means the parser is behind the client: the stretch is then sent WITHOUT tokens — never with a
|
|
438
|
+
* guess, the same rule as an untimed stretch. `shapeKnown` says which happened.
|
|
439
|
+
*/
|
|
440
|
+
const USAGE_KEYS = ["input_tokens", "output_tokens", "cache_creation_input_tokens", "cache_read_input_tokens"];
|
|
441
|
+
function usageShapeKnown(usage) {
|
|
442
|
+
if (!usage || typeof usage !== "object") return false;
|
|
443
|
+
if (!("input_tokens" in usage) && !("output_tokens" in usage)) return false;
|
|
444
|
+
return USAGE_KEYS.every((key) => !(key in usage) || usage[key] === null || (typeof usage[key] === "number" && Number.isFinite(usage[key]) && usage[key] >= 0));
|
|
445
|
+
}
|
|
446
|
+
function tokensOf(messages) {
|
|
447
|
+
let tokensIn = 0, tokensOut = 0, cacheRead = 0, cacheWrite = 0, counted = 0, shapeKnown = true;
|
|
448
|
+
for (const message of messages) {
|
|
449
|
+
const usage = message.usage;
|
|
450
|
+
if (!usage) continue;
|
|
451
|
+
if (!usageShapeKnown(usage)) { shapeKnown = false; break; }
|
|
452
|
+
counted += 1;
|
|
453
|
+
tokensIn += (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
|
|
454
|
+
tokensOut += usage.output_tokens ?? 0;
|
|
455
|
+
cacheRead += usage.cache_read_input_tokens ?? 0;
|
|
456
|
+
cacheWrite += usage.cache_creation_input_tokens ?? 0;
|
|
457
|
+
}
|
|
458
|
+
if (!shapeKnown) return { tokensIn: 0, tokensOut: 0, cacheRead: 0, cacheWrite: 0, shapeKnown: false };
|
|
459
|
+
return { tokensIn, tokensOut, cacheRead, cacheWrite, shapeKnown: counted > 0 };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** The one payload shape this script can build. Every key is a number, a date or an enum value. */
|
|
463
|
+
function payloadFor(stretch) {
|
|
464
|
+
const cwd = stretch.cwd && existsSync(stretch.cwd) ? stretch.cwd : null;
|
|
465
|
+
const repo = cwd ? repoOf(cwd) : null;
|
|
466
|
+
const touched = cwd ? pathsTouched(cwd, new Date(stretch.from).toISOString(), new Date(stretch.to).toISOString()) : { paths: [], committed: false, shas: [] };
|
|
467
|
+
const { layer, layers } = layersOf(touched.paths);
|
|
468
|
+
// The seventh kind, used as it is defined: a stretch that changed no artefact is knowing
|
|
469
|
+
// something, not building something, and it earns neither an artefact's nor a validation's credit.
|
|
470
|
+
const kind = touched.paths.length === 0 ? "researched" : touched.committed ? "changed" : "built";
|
|
471
|
+
return {
|
|
472
|
+
title: layer ? `AI-assisted work · ${layer}` : "AI-assisted work",
|
|
473
|
+
kind,
|
|
474
|
+
...(layer ? { layer } : {}),
|
|
475
|
+
...(layers.length > 1 ? { layers } : {}),
|
|
476
|
+
// Both, or neither: seconds without a basis is exactly the ambiguity the record is trying to
|
|
477
|
+
// lose, and a basis without seconds says nothing.
|
|
478
|
+
...(stretch.seconds !== null ? { seconds: stretch.seconds, duration_basis: "measured" } : {}),
|
|
479
|
+
// Tokens only when the usage block's shape is the one this script knows — otherwise nothing, never a guess.
|
|
480
|
+
...(stretch.shapeKnown && stretch.tokensIn + stretch.tokensOut > 0 ? { tokens_in: stretch.tokensIn, tokens_out: stretch.tokensOut, token_basis: "newInput" } : {}),
|
|
481
|
+
// The turns are the person's own messages, tool results excluded — said explicitly, because a
|
|
482
|
+
// row that does not say reads as the old count, which included them.
|
|
483
|
+
...(stretch.exchanges > 0 ? { turn_basis: "humanOnly" } : {}),
|
|
484
|
+
// The cache halves, as counts, beside the tokens (roadmap 16): read from a cache, written into one.
|
|
485
|
+
...(stretch.shapeKnown && stretch.cacheRead > 0 ? { tokens_cache_read: stretch.cacheRead } : {}),
|
|
486
|
+
...(stretch.shapeKnown && stretch.cacheWrite > 0 ? { tokens_cache_write: stretch.cacheWrite } : {}),
|
|
487
|
+
...(stretch.exchanges > 0 ? { exchanges: stretch.exchanges } : {}),
|
|
488
|
+
...(stretch.model ? { model: stretch.model } : {}),
|
|
489
|
+
...(repo ? { repo } : {}),
|
|
490
|
+
...(touched.shas.length > 0 ? { commits: touched.shas.slice(0, 50) } : {}),
|
|
491
|
+
at: new Date(stretch.to).toISOString(),
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* The history door, in the batch size it asks for. Oldest first, and a resend is safe: every entry
|
|
497
|
+
* is deduplicated on its content, and one that knows more than a stored line FILLS it rather than
|
|
498
|
+
* adding a twin. Returns how many entries were accepted, so a caller can advance a watermark over
|
|
499
|
+
* exactly what landed. `announce` because one caller is a person watching a terminal and the other
|
|
500
|
+
* is a hook holding a session's exit open.
|
|
501
|
+
*/
|
|
502
|
+
async function sendHistory(door, entries, announce = false) {
|
|
503
|
+
let accepted = 0;
|
|
504
|
+
// A Codex entry goes through Codex's own coupling where one exists (see codexDoor), the rest
|
|
505
|
+
// through the door given: the record files a line under the coupling that sent it.
|
|
506
|
+
for (const [source, group] of [["claude", entries.filter((entry) => entrySource.get(entry) !== "codex")], ["codex", entries.filter((entry) => entrySource.get(entry) === "codex")]]) {
|
|
507
|
+
const target = source === "codex" ? codexDoor ?? door : door;
|
|
508
|
+
if (group.length === 0 || !target) continue;
|
|
509
|
+
for (let i = 0; i < group.length; i += 200) {
|
|
510
|
+
const batch = group.slice(i, i + 200);
|
|
511
|
+
// History carries no shas: an imported line forms no session, and the join is the live route's.
|
|
512
|
+
const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "import_history", arguments: { entries: batch.map((entry) => { const { commits, ...rest } = entry; void commits; return rest; }) } } });
|
|
513
|
+
const response = await fetch(target.url, { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${target.token}`, ...proofFor(target.url, body) }, body });
|
|
514
|
+
const text = await response.text();
|
|
515
|
+
if (announce) console.log(`batch ${Math.floor(i / 200) + 1}: ${response.status} ${text.slice(0, 200).replace(/\\n/g, " ")}`);
|
|
516
|
+
if (!response.ok) break;
|
|
517
|
+
accepted += batch.length;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return accepted;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** DEVICE PROOF (2026-10-03): through `worktrust hook` a bound computer hands over WORKTRUST_DEVICE_KEY; every call is then signed. */
|
|
524
|
+
const proofFor = (url, body) => {
|
|
525
|
+
const device = process.env.WORKTRUST_DEVICE_KEY;
|
|
526
|
+
if (!device) return {};
|
|
527
|
+
const seconds = String(Math.floor(Date.now() / 1000));
|
|
528
|
+
const nonce = randomBytes(18).toString("base64url");
|
|
529
|
+
return { "worktrust-proof": `${seconds}.${nonce}.${cryptoSign(null, Buffer.from(`POST\n${new URL(url).pathname}\n${seconds}\n${nonce}\n${createHash("sha256").update(body).digest("hex")}`), device).toString("base64url")}` };
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
async function send(door, payload) {
|
|
533
|
+
const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "log_work", arguments: payload } });
|
|
534
|
+
const response = await fetch(door.url, { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${door.token}`, ...proofFor(door.url, body) }, body });
|
|
535
|
+
if (!response.ok) throw new Error(`door said ${response.status}`);
|
|
536
|
+
return response.text();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* THE MONTHS ALREADY ON THIS MACHINE, AS HISTORY.
|
|
541
|
+
*
|
|
542
|
+
* The hook records what happens from today. Everything before it sits in the same transcripts,
|
|
543
|
+
* with the same clocks, and the door has a separate path for it — `import_history`, which marks a
|
|
544
|
+
* line as imported: it forms no session, adds no verified hour and carries no level, so nothing
|
|
545
|
+
* here can inflate a credential. What it CAN do is give the record days, months, layers and a
|
|
546
|
+
* rhythm it had no way to know about, and it is the only half of the past that is honest to send:
|
|
547
|
+
* a measured stretch from the person's own machine, not an estimate of what probably happened.
|
|
548
|
+
*
|
|
549
|
+
* One entry per DAY per transcript. A file may span a week; a day is the unit the reconstruction
|
|
550
|
+
* matches commits by, and cutting on the calendar keeps `at` a real instant rather than an
|
|
551
|
+
* average of one. Younger than the live window is left alone — that is `log_work`'s half.
|
|
552
|
+
*/
|
|
553
|
+
function historyEntries() {
|
|
554
|
+
const entries = [];
|
|
555
|
+
for (const { file } of allTranscripts()) {
|
|
556
|
+
const lines = parsedLines(file);
|
|
557
|
+
if (!lines) continue;
|
|
558
|
+
let cwd = null;
|
|
559
|
+
const byDay = new Map();
|
|
560
|
+
for (const line of lines) {
|
|
561
|
+
if (typeof line.cwd === "string") cwd = line.cwd;
|
|
562
|
+
if (!line.timestamp || (line.type !== "user" && line.type !== "assistant")) continue;
|
|
563
|
+
const at = Date.parse(line.timestamp);
|
|
564
|
+
if (!Number.isFinite(at)) continue;
|
|
565
|
+
const day = new Date(at).toISOString().slice(0, 10);
|
|
566
|
+
if (!byDay.has(day)) byDay.set(day, []);
|
|
567
|
+
byDay.get(day).push({ at, type: line.type, usage: line.message?.usage ?? null, model: line.message?.model ?? null, meta: line.isMeta === true, content: line.message?.content ?? null });
|
|
568
|
+
}
|
|
569
|
+
for (const [day, messages] of byDay) {
|
|
570
|
+
if (messages.length === 0) continue;
|
|
571
|
+
messages.sort((a, b) => a.at - b.at);
|
|
572
|
+
const last = messages[messages.length - 1].at;
|
|
573
|
+
// The live window belongs to the hook: a stretch that log_work may still report is not history.
|
|
574
|
+
if (Date.now() - last < WINDOW_HOURS * 3600_000) continue;
|
|
575
|
+
let seconds = 0;
|
|
576
|
+
for (let i = 1; i < messages.length; i += 1) seconds += Math.min(IDLE_CAP, Math.round((messages[i].at - messages[i - 1].at) / 1000));
|
|
577
|
+
const models = new Map();
|
|
578
|
+
let exchanges = 0;
|
|
579
|
+
for (const message of messages) {
|
|
580
|
+
if (message.type === "user" && !message.meta && isHumanTurn(message.content)) exchanges += 1;
|
|
581
|
+
if (message.model) models.set(message.model, (models.get(message.model) ?? 0) + 1);
|
|
582
|
+
}
|
|
583
|
+
const { tokensIn, tokensOut, cacheRead, cacheWrite, shapeKnown } = tokensOf(messages);
|
|
584
|
+
const entry = payloadFor({
|
|
585
|
+
cwd, seconds: seconds >= MIN_SECONDS ? Math.min(MAX_SECONDS, seconds) : null,
|
|
586
|
+
tokensIn, tokensOut, cacheRead, cacheWrite, shapeKnown, exchanges, model: [...models.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null,
|
|
587
|
+
from: messages[0].at, to: last,
|
|
588
|
+
});
|
|
589
|
+
if (CODEX_ROLLOUT.test(file)) entrySource.set(entry, "codex");
|
|
590
|
+
entries.push(entry);
|
|
591
|
+
void day;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return entries.sort((a, b) => a.at.localeCompare(b.at));
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** Every transcript this machine holds, excluded projects skipped before a file is opened. */
|
|
598
|
+
function* allTranscripts() {
|
|
599
|
+
const root = claudeRoot();
|
|
600
|
+
if (existsSync(root)) for (const dir of readdirSync(root)) {
|
|
601
|
+
if (excluded(dir)) continue;
|
|
602
|
+
let files;
|
|
603
|
+
try { files = readdirSync(join(root, dir)); } catch { continue; }
|
|
604
|
+
for (const file of files) if (file.endsWith(".jsonl")) yield { id: file.replace(/\.jsonl$/, ""), file: join(root, dir, file) };
|
|
605
|
+
}
|
|
606
|
+
yield* codexRollouts();
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* EVERY TRANSCRIPT, with what it was last written. The two filters that used to live here decided
|
|
611
|
+
* the wrong thing at the wrong level:
|
|
612
|
+
*
|
|
613
|
+
* - "idle 30 minutes" skipped the file being typed into, and with it every earlier day of a
|
|
614
|
+
* session that has been open for a fortnight. Whether a DAY is finished is a fact about the day.
|
|
615
|
+
* - "not older than 48 hours" skipped the machine that was closed for four months, which is
|
|
616
|
+
* exactly the person whose work needs collecting when they come back.
|
|
617
|
+
*
|
|
618
|
+
* The caller opens a file only when it has been written since the watermark says we last read it,
|
|
619
|
+
* so a sweep over a hundred transcripts costs a hundred `stat` calls and nothing more.
|
|
620
|
+
*/
|
|
621
|
+
function* candidates() {
|
|
622
|
+
for (const rollout of codexRollouts()) {
|
|
623
|
+
let stat; try { stat = statSync(rollout.file); } catch { continue; }
|
|
624
|
+
yield { ...rollout, writtenAt: stat.mtimeMs };
|
|
625
|
+
}
|
|
626
|
+
const root = claudeRoot();
|
|
627
|
+
if (!existsSync(root)) return;
|
|
628
|
+
for (const dir of readdirSync(root)) {
|
|
629
|
+
if (excluded(dir)) continue;
|
|
630
|
+
const path = join(root, dir);
|
|
631
|
+
let files;
|
|
632
|
+
try { files = readdirSync(path); } catch { continue; }
|
|
633
|
+
for (const file of files) {
|
|
634
|
+
if (!file.endsWith(".jsonl")) continue;
|
|
635
|
+
const full = join(path, file);
|
|
636
|
+
let stat;
|
|
637
|
+
try { stat = statSync(full); } catch { continue; }
|
|
638
|
+
yield { id: file.replace(/\.jsonl$/, ""), file: full, writtenAt: stat.mtimeMs };
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function hookInput() {
|
|
644
|
+
// Only when something was piped in. Reading fd 0 on a terminal waits for an EOF that never
|
|
645
|
+
// comes, and a hook that hangs is worse than one that does nothing. A dry run READS it, so the
|
|
646
|
+
// hook path is the one a person can rehearse: `echo '<the hook JSON>' | node log-session.mjs
|
|
647
|
+
// --dry-run` shows exactly what that session would send.
|
|
648
|
+
if (process.stdin.isTTY) return null;
|
|
649
|
+
try {
|
|
650
|
+
const raw = readFileSync(0, "utf8");
|
|
651
|
+
return raw.trim() ? JSON.parse(raw) : null;
|
|
652
|
+
} catch { return null; }
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (flag("uninstall")) {
|
|
656
|
+
// THE WAY OUT IS THE SAME SIZE AS THE WAY IN. A hook that can only be installed is a hook
|
|
657
|
+
// somebody has to reverse-engineer out of a settings file, and it keeps sending in the
|
|
658
|
+
// meantime. This removes the wiring; --purge also removes the copies and the state.
|
|
659
|
+
const file = join(homedir(), ".claude", "settings.json");
|
|
660
|
+
const settings = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
|
|
661
|
+
const before = JSON.stringify(HOOK_EVENTS.map((event) => settings.hooks?.[event] ?? []));
|
|
662
|
+
for (const event of HOOK_EVENTS) {
|
|
663
|
+
if (!settings.hooks?.[event]) continue;
|
|
664
|
+
settings.hooks[event] = settings.hooks[event]
|
|
665
|
+
.map((entry) => ({ ...entry, hooks: (entry?.hooks ?? []).filter((item) => !ours(item?.command)) }))
|
|
666
|
+
.filter((entry) => (entry.hooks ?? []).length > 0);
|
|
667
|
+
if (settings.hooks[event].length === 0) delete settings.hooks[event];
|
|
668
|
+
}
|
|
669
|
+
const removed = before !== JSON.stringify(HOOK_EVENTS.map((event) => settings.hooks?.[event] ?? []));
|
|
670
|
+
if (removed) writeFileSync(file, JSON.stringify(settings, null, 2));
|
|
671
|
+
if (flag("purge")) for (const path of [COUNTER, PINNED, join(homedir(), ".worktrust", "log-session.mjs"), STATE]) { try { rmSync(path); } catch { /* already gone */ } }
|
|
672
|
+
console.log(`${removed ? "removed" : "nothing wired"}: ${file}${flag("purge") ? "\n purged ~/.worktrust" : ""}`);
|
|
673
|
+
console.log("the coupling itself still exists: end it in the app (Sources → the computer's row → end this coupling)");
|
|
674
|
+
process.exit(0);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (flag("install")) {
|
|
678
|
+
// Wired to SessionEnd, not to every reply: the door asks for one call per PIECE of work, and a
|
|
679
|
+
// hook that fired per message would report a hundred stretches for one afternoon.
|
|
680
|
+
//
|
|
681
|
+
// AND WIRED TO A PATH THAT SURVIVES. `curl -O` leaves the file wherever the terminal happened to
|
|
682
|
+
// be — a Downloads folder, a repository, a temp directory somebody empties on Monday — and a
|
|
683
|
+
// hook whose command has gone missing fails silently, which on this record is indistinguishable
|
|
684
|
+
// from a person who stopped working. So install copies itself to ~/.worktrust and wires that.
|
|
685
|
+
const home = join(homedir(), ".worktrust", "log-session.mjs");
|
|
686
|
+
const self = process.argv[1];
|
|
687
|
+
// The copy that runs is refreshed from the file you ran — and SAYS so, because "already installed —
|
|
688
|
+
// nothing changed" after a newer download read as "your hook is old" to the person who had just updated it.
|
|
689
|
+
let hookRefreshed = false;
|
|
690
|
+
if (self !== home) {
|
|
691
|
+
const next = readFileSync(self, "utf8");
|
|
692
|
+
const current = existsSync(home) ? readFileSync(home, "utf8") : null;
|
|
693
|
+
mkdirSync(dirname(home), { recursive: true });
|
|
694
|
+
if (current !== next) { writeFileSync(home, next); hookRefreshed = current !== null; }
|
|
695
|
+
}
|
|
696
|
+
// A counter handed over by the package (`--counter <path>`) is copied and pinned: nothing below fetches one.
|
|
697
|
+
const given = args.indexOf("--counter") >= 0 ? args[args.indexOf("--counter") + 1] : null;
|
|
698
|
+
if (given) { mkdirSync(dirname(COUNTER), { recursive: true }); writeFileSync(COUNTER, readFileSync(given, "utf8")); writeFileSync(PINNED, "the counter comes with the worktrust package; it is not refreshed from the network\n"); console.log(`counter: ${COUNTER} — from the package, pinned`); }
|
|
699
|
+
const file = join(homedir(), ".claude", "settings.json");
|
|
700
|
+
const settings = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
|
|
701
|
+
// `--via <worktrust.mjs>`: the key lives in ~/.worktrust/key.json, so Claude Code runs the wrapper, which reads it and starts this hook.
|
|
702
|
+
const via = args.indexOf("--via") >= 0 ? args[args.indexOf("--via") + 1] : null;
|
|
703
|
+
const command = via ? `"${process.env.WORKTRUST_NODE || process.execPath}" "${via}" hook` : `node ${home}`;
|
|
704
|
+
settings.hooks ??= {};
|
|
705
|
+
for (const event of HOOK_EVENTS) settings.hooks[event] ??= [];
|
|
706
|
+
// A WIRING THAT POINTS SOMEWHERE ELSE IS REPAIRED, NOT LEFT. An earlier install wired whatever
|
|
707
|
+
// path `curl -O` had left the file at — a repository working tree, in the first real install —
|
|
708
|
+
// and answering "already installed" to the run that would have fixed it leaves the person
|
|
709
|
+
// certain it is right. So an entry that names this hook and a different path is rewritten.
|
|
710
|
+
let repaired = null;
|
|
711
|
+
const wired = new Set();
|
|
712
|
+
for (const event of HOOK_EVENTS) {
|
|
713
|
+
for (const entry of settings.hooks[event]) {
|
|
714
|
+
for (const item of entry?.hooks ?? []) {
|
|
715
|
+
if (typeof item?.command !== "string" || !ours(item.command)) continue;
|
|
716
|
+
if (item.command === command) { wired.add(event); continue; }
|
|
717
|
+
repaired = item.command;
|
|
718
|
+
item.command = command;
|
|
719
|
+
wired.add(event);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
// An earlier install wired SessionEnd alone. Adding the two that were missing is a repair, not a
|
|
724
|
+
// second installation, and saying "already installed" to the run that would have added them is
|
|
725
|
+
// how a person ends up certain that a thing works which does not.
|
|
726
|
+
const added = HOOK_EVENTS.filter((event) => !wired.has(event));
|
|
727
|
+
const already = added.length === 0;
|
|
728
|
+
// The counting half, from the same deployment the hook came from: one command installs both, or
|
|
729
|
+
// says plainly that it could not — a half nobody knows is missing is the worse outcome.
|
|
730
|
+
const counterOrigin = coupling()?.url ? new URL(coupling().url).origin : null;
|
|
731
|
+
if (existsSync(COUNTER)) await refreshCounter(counterOrigin, (line) => console.log(line));
|
|
732
|
+
if (!existsSync(COUNTER)) {
|
|
733
|
+
const origin = counterOrigin;
|
|
734
|
+
if (!origin) console.log("counter: not installed — no coupling found to name the deployment; fetch /counter/count-behaviour.mjs yourself");
|
|
735
|
+
else {
|
|
736
|
+
try {
|
|
737
|
+
const response = await fetch(`${origin}/counter/count-behaviour.mjs`);
|
|
738
|
+
if (!response.ok) throw new Error(String(response.status));
|
|
739
|
+
writeFileSync(COUNTER, await response.text());
|
|
740
|
+
console.log(`counter: ${COUNTER} — it runs once a day from this hook and STAGES its reading; nothing counts until you click Import in the app`);
|
|
741
|
+
} catch (error) { console.log(`counter: could not fetch it (${error.message}) — the hook works without it; fetch /counter/count-behaviour.mjs later`); }
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
if (already && !repaired) { console.log(hookRefreshed ? `hook updated: ${home} now runs the version you downloaded; the wiring was already right (${command})` : `already installed — nothing changed (${command})`); process.exit(0); }
|
|
745
|
+
for (const event of added) settings.hooks[event].push({ hooks: [{ type: "command", command }] });
|
|
746
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
747
|
+
writeFileSync(file, JSON.stringify(settings, null, 2));
|
|
748
|
+
console.log(repaired
|
|
749
|
+
? `repaired: ${file} pointed at ${repaired}\n it now runs ${command}`
|
|
750
|
+
: `installed: ${home}\n ${file} → ${HOOK_EVENTS.join(", ")} run it\n the file you downloaded may be deleted; this copy is the one that runs`);
|
|
751
|
+
// Codex has no settings.json to wire; its notify hook is one line in config.toml, added by hand so
|
|
752
|
+
// this script never rewrites a TOML file it did not create. Without it, Codex rollouts are still
|
|
753
|
+
// swept whenever the Claude Code hook runs.
|
|
754
|
+
if (existsSync(join(homedir(), ".codex"))) console.log(`codex: to have Codex wake this hook too, add to ~/.codex/config.toml:\n notify = ${via ? `["${process.execPath}", "${via}", "hook", "--codex-notify"]` : `["node", "${home}", "--codex-notify"]`}\n (its rollouts are swept on every run either way)`);
|
|
755
|
+
process.exit(0);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const door = coupling();
|
|
759
|
+
/**
|
|
760
|
+
* A CODEX STRETCH GOES THROUGH CODEX'S OWN COUPLING. Both clients hold a token to the same door,
|
|
761
|
+
* and the record files a stretch under the coupling that sent it: a Codex thread sent with Claude
|
|
762
|
+
* Code's token would read as Claude Code work. Where only one of the two is coupled, that one
|
|
763
|
+
* carries both, and the model on every stretch still says which it was.
|
|
764
|
+
*/
|
|
765
|
+
const codexDoor = codexCoupling();
|
|
766
|
+
const doorFor = (entry) => (String(entry?.id ?? "").startsWith("codex:") ? codexDoor ?? door : door);
|
|
767
|
+
const entrySource = new WeakMap(); // a history entry → "codex" when its rollout was Codex's; sendHistory routes by it
|
|
768
|
+
if (!door && !codexDoor && !DRY) { process.exit(0); } // Not coupled on this machine: nothing to do, quietly.
|
|
769
|
+
|
|
770
|
+
if (flag("history")) {
|
|
771
|
+
const entries = historyEntries();
|
|
772
|
+
const hours = entries.reduce((sum, entry) => sum + (entry.seconds ?? 0), 0) / 3600;
|
|
773
|
+
const months = [...new Set(entries.map((entry) => entry.at.slice(0, 7)))].sort();
|
|
774
|
+
console.log(`${entries.length} day-stretches · ${hours.toFixed(1)} measured hours · ${months.join(", ")}`);
|
|
775
|
+
if (DRY) {
|
|
776
|
+
// One example in full: the shape is the argument for trusting it, and a summary hides it.
|
|
777
|
+
if (entries[0]) console.log(`\nthe oldest of them, in full:\n${JSON.stringify(entries[0], null, 1)}`);
|
|
778
|
+
console.log("\ndry run — nothing sent. Add --history without --dry-run to import them.");
|
|
779
|
+
process.exit(0);
|
|
780
|
+
}
|
|
781
|
+
await sendHistory(door, entries, true);
|
|
782
|
+
process.exit(0);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const input = hookInput();
|
|
786
|
+
const state = readState();
|
|
787
|
+
const work = [];
|
|
788
|
+
if (input?.transcript_path && existsSync(input.transcript_path) && !excluded(input.transcript_path)) {
|
|
789
|
+
work.push({ id: String(input.session_id ?? input.transcript_path), file: input.transcript_path });
|
|
790
|
+
}
|
|
791
|
+
// A session that ended with the terminal closed fires no hook, so every run also sweeps the
|
|
792
|
+
// transcripts that have gone quiet and were never logged. Self-healing, and it can only ever
|
|
793
|
+
// report a stretch it can measure.
|
|
794
|
+
for (const found of candidates()) if (!work.some((entry) => entry.file === found.file)) work.push(found);
|
|
795
|
+
|
|
796
|
+
let sent = 0;
|
|
797
|
+
let filed = 0;
|
|
798
|
+
/** Measured days the live door will no longer take. They are not dropped; they are filed. */
|
|
799
|
+
const late = [];
|
|
800
|
+
for (const entry of work) {
|
|
801
|
+
const since = state[entry.id]?.through ? Date.parse(state[entry.id].through) : null;
|
|
802
|
+
// Nothing has been written since we last read this one — no need to open it at all. This is what
|
|
803
|
+
// keeps a sweep over every transcript on the machine to one `stat` each.
|
|
804
|
+
if (since !== null && entry.writtenAt && entry.writtenAt <= since) continue;
|
|
805
|
+
const stretches = stretchesOf(entry.file, since);
|
|
806
|
+
// THE WATERMARK ADVANCES OVER WHAT WAS ACCEPTED, AND STOPS AT THE FIRST REFUSAL. A door that is
|
|
807
|
+
// down must never cost the person a day, so the loop breaks rather than skipping ahead.
|
|
808
|
+
let through = null;
|
|
809
|
+
for (const stretch of stretches) {
|
|
810
|
+
// The day still being worked in is not finished. A fortnight-long session therefore reports
|
|
811
|
+
// every day but today, on whichever run comes first — it no longer waits for its own end.
|
|
812
|
+
if (Date.now() - stretch.to < IDLE_BEFORE_SWEEP) break;
|
|
813
|
+
const payload = payloadFor(stretch);
|
|
814
|
+
if (String(entry.id).startsWith("codex:")) entrySource.set(payload, "codex");
|
|
815
|
+
if (DRY) { console.log(JSON.stringify({ ...payload, route: Date.now() - stretch.to > WINDOW_HOURS * 3600_000 ? "history" : "log_work" }, null, 1)); through = stretch.to; continue; }
|
|
816
|
+
// MEASURED TIME IS NEVER DROPPED. The live door takes work as it happens and refuses anything
|
|
817
|
+
// older than two days — which is right, and used to mean a machine that was closed for a
|
|
818
|
+
// fortnight lost the fortnight. The same measured numbers go through the history door instead:
|
|
819
|
+
// recorded, marked as history, standing as history stands. Late is not the same as untrue.
|
|
820
|
+
if (Date.now() - stretch.to > WINDOW_HOURS * 3600_000) { late.push(payload); through = stretch.to; continue; }
|
|
821
|
+
try {
|
|
822
|
+
await send(doorFor(entry), payload);
|
|
823
|
+
through = stretch.to;
|
|
824
|
+
sent += 1;
|
|
825
|
+
} catch (error) {
|
|
826
|
+
if (process.env.WORKTRUST_HOOK_DEBUG) console.error(`worktrust: ${error.message}`);
|
|
827
|
+
break;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
if (through !== null) state[entry.id] = { through: new Date(through).toISOString() };
|
|
831
|
+
}
|
|
832
|
+
// The history door, in one batch per two hundred, exactly as `--history` sends them. A resend
|
|
833
|
+
// FILLS a line that was missing something rather than adding a twin, so a repeated run is safe.
|
|
834
|
+
if (!DRY && late.length > 0) {
|
|
835
|
+
try { filed = await sendHistory(door, late); } catch (error) {
|
|
836
|
+
if (process.env.WORKTRUST_HOOK_DEBUG) console.error(`worktrust: ${error.message}`);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
if (!DRY && (sent > 0 || filed > 0)) writeState(state);
|
|
840
|
+
|
|
841
|
+
// The counter, at most once a day, detached: reading every transcript takes longer than a hook
|
|
842
|
+
// may hold a session's exit, and its result waits for a click either way. `--stage` and never
|
|
843
|
+
// `--send`: staging is idempotent on the reading's own digest, so a day that changed nothing
|
|
844
|
+
// leaves nothing behind.
|
|
845
|
+
if (!DRY && existsSync(COUNTER)) {
|
|
846
|
+
// Before the day's count: the deployment's counter, if it is newer than the one on this machine.
|
|
847
|
+
await refreshCounter(door?.url ? new URL(door.url).origin : null, (line) => console.log(line));
|
|
848
|
+
const last = state.__counter?.at ? Date.parse(state.__counter.at) : 0;
|
|
849
|
+
if (Date.now() - last > COUNTER_EVERY_MS) {
|
|
850
|
+
try {
|
|
851
|
+
const child = spawn(process.execPath, [COUNTER, "--stage"], { detached: true, stdio: "ignore" });
|
|
852
|
+
child.unref();
|
|
853
|
+
state.__counter = { at: new Date().toISOString() };
|
|
854
|
+
writeState(state);
|
|
855
|
+
} catch { /* the counter is a bonus; a hook that fails on it would cost the stretch it just logged */ }
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
process.exit(0);
|