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/log-session.mjs
CHANGED
|
@@ -33,6 +33,9 @@
|
|
|
33
33
|
* CODEX ROLLOUTS (2026-09-27) are swept beside the Claude Code transcripts: ~/.codex/sessions holds
|
|
34
34
|
* the same clocks, the model per turn and a token record per response, and the reader below turns
|
|
35
35
|
* them into the lines this script already measures. One hook, two clients, one rule for both.
|
|
36
|
+
* ANTIGRAVITY (2026-10-04) the same way: its brain/<id>/.system_generated/logs/transcript.jsonl is swept
|
|
37
|
+
* as a third client (clocks and turns; it records no tokens), woken by its own Stop hook through
|
|
38
|
+
* `worktrust.mjs hook --antigravity-stop`. The readers live in transcript-readers.mjs beside this file.
|
|
36
39
|
*
|
|
37
40
|
* WHAT CANNOT LEAVE. The payload is built from a fixed allowlist of keys, all of them numbers,
|
|
38
41
|
* dates or vocabulary from the door's own enums. No prompt, no answer, no file path, no commit
|
|
@@ -51,9 +54,10 @@
|
|
|
51
54
|
* honoured here too, matched against the transcript's project directory, before a file is read.
|
|
52
55
|
*/
|
|
53
56
|
import { execFileSync, spawn } from "node:child_process";
|
|
54
|
-
import {
|
|
57
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
55
58
|
import { homedir } from "node:os";
|
|
56
59
|
import { dirname, join } from "node:path";
|
|
60
|
+
import { fileURLToPath } from "node:url";
|
|
57
61
|
import { createHash, randomBytes, sign as cryptoSign } from "node:crypto";
|
|
58
62
|
|
|
59
63
|
const args = process.argv.slice(2);
|
|
@@ -97,6 +101,7 @@ async function refreshCounter(origin, announce) {
|
|
|
97
101
|
const have = versionOf(readFileSync(COUNTER, "utf8")), theirs = versionOf(remote);
|
|
98
102
|
if (!newer(theirs, have)) return false;
|
|
99
103
|
writeFileSync(COUNTER, remote);
|
|
104
|
+
await installReaders(origin, true);
|
|
100
105
|
announce?.(`counter: updated to counter@${theirs.join(".")} (was counter@${have?.join(".") ?? "?"}) — the next reading counts under the new rubric`);
|
|
101
106
|
return true;
|
|
102
107
|
} catch { return false; }
|
|
@@ -139,6 +144,15 @@ const HOOK_EVENTS = ["SessionEnd", "SessionStart", "PreCompact"];
|
|
|
139
144
|
const STATE = join(homedir(), ".worktrust", "sessions.json");
|
|
140
145
|
const readState = () => { try { return JSON.parse(readFileSync(STATE, "utf8")); } catch { return {}; } };
|
|
141
146
|
const writeState = (state) => { mkdirSync(dirname(STATE), { recursive: true }); writeFileSync(STATE, JSON.stringify(state, null, 1)); };
|
|
147
|
+
/**
|
|
148
|
+
* THE FLOORS (owner, 2026-10-04: "history is never sent silently"). `__from` is the moment this
|
|
149
|
+
* computer was coupled: the hook sends nothing measured before it, so a fresh coupling (or one after
|
|
150
|
+
* `disconnect` purged the watermarks) never sweeps the computer's past into the record unasked. That
|
|
151
|
+
* past is offered once, by `npx worktrust`, and goes only on a yes. `__historyFloor` is set when the
|
|
152
|
+
* computer changed ACCOUNT: even that offer then starts at the change, so one person's earlier work
|
|
153
|
+
* is never offered to the next account.
|
|
154
|
+
*/
|
|
155
|
+
const floorOf = (state, key) => { const at = typeof state?.[key] === "string" ? Date.parse(state[key]) : NaN; return Number.isFinite(at) ? at : null; };
|
|
142
156
|
|
|
143
157
|
/**
|
|
144
158
|
* ONLY THE HOUSE'S OWN DOOR (2026-09-27, audit): the coupling used to be "any MCP server whose URL
|
|
@@ -181,119 +195,64 @@ const excludes = (() => {
|
|
|
181
195
|
try { return (JSON.parse(readFileSync(join(homedir(), ".worktrust-counter.json"), "utf8")).exclude ?? []).map(String); } catch { return []; }
|
|
182
196
|
})();
|
|
183
197
|
const excluded = (path) => excludes.some((text) => text && path.includes(text));
|
|
184
|
-
// ── codex rollout reader (the same text in count-behaviour.mjs and log-session.mjs; test-codex-rollout holds them equal) ──
|
|
185
198
|
/**
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* token_usage_record per model response. On the machine this was built on five of them held 679
|
|
190
|
-
* million tokens that nothing read. This turns the records into the lines Claude Code writes, so
|
|
191
|
-
* ONE rubric and ONE clock read both:
|
|
192
|
-
* · a user or assistant message → a user or assistant line with a text part; the developer line
|
|
193
|
-
* and the harness's own user-role messages (<environment_context>, <recommended_plugins> and
|
|
194
|
-
* the like: a tag opening the text) are the harness, not the person, and are dropped;
|
|
195
|
-
* · a tool call → an assistant line with a tool_use part: `exec` becomes Bash with the command
|
|
196
|
-
* as input, `apply_patch` an Edit with the first path it names, `spawn_agent` an Agent; its
|
|
197
|
-
* output → a user line with a tool_result, is_error read from Codex's own "exit code N";
|
|
198
|
-
* · a token_usage_record → an assistant line carrying the usage in Claude's keys, ONCE per
|
|
199
|
-
* response_id (Codex writes a response more than once): input minus cached is new input,
|
|
200
|
-
* cached is a cache read, cache_write a cache write, output already holds the reasoning tokens.
|
|
201
|
-
* The running total in event_msg/token_count is never read: it is a counter, not a record.
|
|
202
|
-
* Text is read here for the rubric and the layer and travels nowhere, as with every transcript.
|
|
199
|
+
* THE READERS live beside this file: Codex and Antigravity in transcript-readers.mjs (the counter imports it too, one
|
|
200
|
+
* reader), the database clients in session-databases.mjs. The install copies both into ~/.worktrust with this hook.
|
|
201
|
+
* Without them this hook still reads Claude Code; the never-matching patterns keep every other client out.
|
|
203
202
|
*/
|
|
204
|
-
const CODEX_ROLLOUT = /(
|
|
205
|
-
const
|
|
206
|
-
const
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
};
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
function* codexLines(records) {
|
|
219
|
-
let cwd = null, model = null;
|
|
220
|
-
const seenResponses = new Set();
|
|
221
|
-
for (const raw of records) {
|
|
222
|
-
if (!raw || !String(raw).trim()) continue;
|
|
223
|
-
let record; try { record = JSON.parse(raw); } catch { continue; }
|
|
224
|
-
const timestamp = typeof record?.timestamp === "string" ? record.timestamp : null;
|
|
225
|
-
const payload = record?.payload && typeof record.payload === "object" ? record.payload : {};
|
|
226
|
-
if (record.type === "session_meta") { if (typeof payload.cwd === "string") cwd = payload.cwd; continue; }
|
|
227
|
-
if (record.type === "turn_context") { if (typeof payload.cwd === "string") cwd = payload.cwd; if (typeof payload.model === "string") model = payload.model; continue; }
|
|
228
|
-
if (record.type === "token_usage_record") {
|
|
229
|
-
const id = String(payload.response_id ?? "");
|
|
230
|
-
const usage = payload.usage;
|
|
231
|
-
if (!usage || typeof usage !== "object" || (id && seenResponses.has(id))) continue;
|
|
232
|
-
if (id) seenResponses.add(id);
|
|
233
|
-
const n = (key) => (typeof usage[key] === "number" && Number.isFinite(usage[key]) ? usage[key] : 0);
|
|
234
|
-
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") } } };
|
|
235
|
-
continue;
|
|
236
|
-
}
|
|
237
|
-
if (record.type !== "response_item") continue;
|
|
238
|
-
const kind = payload.type;
|
|
239
|
-
if (kind === "message") {
|
|
240
|
-
if (payload.role !== "user" && payload.role !== "assistant") continue;
|
|
241
|
-
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 : "";
|
|
242
|
-
if (payload.role === "user" && CODEX_HARNESS_TURN.test(text)) continue;
|
|
243
|
-
yield { type: payload.role, timestamp, cwd, uuid: payload.id ? `codex-${payload.id}` : undefined, message: { ...(payload.role === "assistant" && model ? { model } : {}), content: [{ type: "text", text }] } };
|
|
244
|
-
continue;
|
|
245
|
-
}
|
|
246
|
-
if (kind === "custom_tool_call" || kind === "function_call") {
|
|
247
|
-
const input = kind === "function_call" ? (() => { try { return JSON.parse(payload.arguments ?? "{}"); } catch { return {}; } })() : payload.input;
|
|
248
|
-
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) }] } };
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
if (kind === "custom_tool_call_output" || kind === "function_call_output") {
|
|
252
|
-
const text = codexOutputText(payload.output);
|
|
253
|
-
const exit = /(?:failed with|exited with|exit code)[:\s]+(-?\d+)/i.exec(text.slice(-400));
|
|
254
|
-
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 }] } };
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
/** Every rollout under a Codex sessions root, in a stable order. */
|
|
259
|
-
function* codexRolloutFiles(root) {
|
|
260
|
-
let entries = []; try { entries = readdirSync(root, { withFileTypes: true }); } catch { return; }
|
|
261
|
-
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
262
|
-
const full = join(root, entry.name);
|
|
263
|
-
if (entry.isDirectory()) yield* codexRolloutFiles(full);
|
|
264
|
-
else if (CODEX_ROLLOUT.test(entry.name)) yield full;
|
|
203
|
+
const { CODEX_ROLLOUT = /(?!)/, codexLines, codexRolloutFiles = function* () {}, codexCwd = () => null, ANTIGRAVITY_TRANSCRIPT = /(?!)/, antigravityLines, antigravityRoots = () => [], antigravityTranscripts = function* () {}, antigravityContext = () => ({}), rememberAntigravity = () => null } = (await import("./transcript-readers.mjs").catch(() => null)) ?? {};
|
|
204
|
+
const { DATABASE_SESSION = /(?!)/, databaseLines = () => null, databaseSessions = function* () {} } = (await import("./session-databases.mjs").catch(() => null)) ?? {};
|
|
205
|
+
const DATABASE_CLIENTS = ["hermes", "goose", "opencode", "openclaw"]; // the registry's keys, the name each line carries
|
|
206
|
+
const READER_FILES = ["transcript-readers.mjs", "session-databases.mjs"];
|
|
207
|
+
/** The readers go where the hook and the counter run: copied from beside this file, else (or for a newer counter) from the deployment. */
|
|
208
|
+
async function installReaders(origin, fromNetwork = false) {
|
|
209
|
+
let installed = true;
|
|
210
|
+
for (const name of READER_FILES) { // Hermes, Goose, OpenCode and OpenClaw are read by session-databases.mjs
|
|
211
|
+
const here = join(dirname(fileURLToPath(import.meta.url)), name), home = join(homedir(), ".worktrust", name);
|
|
212
|
+
try {
|
|
213
|
+
if (!fromNetwork && existsSync(here)) { mkdirSync(dirname(home), { recursive: true }); if (here !== home) copyFileSync(here, home); continue; }
|
|
214
|
+
const response = origin ? await fetch(`${origin}/counter/${name}`) : null;
|
|
215
|
+
if (response?.ok) writeFileSync(home, await response.text()); else installed = false;
|
|
216
|
+
} catch { installed = false; }
|
|
265
217
|
}
|
|
218
|
+
return installed;
|
|
266
219
|
}
|
|
267
|
-
/** The rollout's working directory, from its first line alone: the file is not read for it. */
|
|
268
|
-
function codexCwd(file) {
|
|
269
|
-
let fd; try { fd = openSync(file, "r"); } catch { return null; }
|
|
270
|
-
try {
|
|
271
|
-
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
272
|
-
const read = readSync(fd, buffer, 0, buffer.length, 0);
|
|
273
|
-
const first = buffer.toString("utf8", 0, read).split("\n")[0] ?? "";
|
|
274
|
-
const meta = JSON.parse(first);
|
|
275
|
-
return meta?.type === "session_meta" && typeof meta.payload?.cwd === "string" ? meta.payload.cwd : null;
|
|
276
|
-
} catch { return null; } finally { closeSync(fd); }
|
|
277
|
-
}
|
|
278
|
-
// ── end codex rollout reader ──
|
|
279
220
|
|
|
280
221
|
/** The roots this machine's transcripts live under; a test names its own. */
|
|
281
222
|
const claudeRoot = () => value("transcript-root") ?? join(homedir(), ".claude", "projects");
|
|
282
223
|
const codexRoot = () => { const given = value("codex-root"); if (given) return given.replace(/^~(?=\/|$)/, homedir()); return value("transcript-root") ? null : join(homedir(), ".codex", "sessions"); };
|
|
283
|
-
/** Every Codex rollout on this machine, an excluded project's threads skipped by their working directory. */
|
|
224
|
+
/** Every Codex rollout and Antigravity conversation on this machine, an excluded project's threads skipped by their working directory. */
|
|
284
225
|
function* codexRollouts() {
|
|
285
226
|
const root = codexRoot();
|
|
286
|
-
|
|
287
|
-
for (const file of codexRolloutFiles(root)) {
|
|
227
|
+
for (const file of root ? codexRolloutFiles(root) : []) {
|
|
288
228
|
const cwd = codexCwd(file);
|
|
289
229
|
if (cwd && excluded(cwd)) continue;
|
|
290
230
|
yield { id: `codex:${file.split("/").at(-1).replace(/\.jsonl$/, "")}`, file };
|
|
291
231
|
}
|
|
232
|
+
yield* antigravityConversations();
|
|
233
|
+
yield* databaseConversations();
|
|
234
|
+
}
|
|
235
|
+
/** The attended sessions of the clients that keep a database (session-databases.mjs); an excluded folder skipped. */
|
|
236
|
+
function* databaseConversations() {
|
|
237
|
+
const given = Object.fromEntries(DATABASE_CLIENTS.map((client) => [client, value(`${client}-root`)]).filter(([, root]) => root));
|
|
238
|
+
for (const { client, sessionId, file, cwd, writtenAt } of databaseSessions(given, Boolean(value("transcript-root")))) {
|
|
239
|
+
if (!(cwd && excluded(cwd))) yield { id: `${client}:${sessionId}`, file, writtenAt };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/** Antigravity beside Codex: one transcript per conversation, its folder known only from the Stop hook. */
|
|
243
|
+
function* antigravityConversations() {
|
|
244
|
+
const given = value("antigravity-root");
|
|
245
|
+
for (const { conversationId, file } of antigravityTranscripts(given || !value("transcript-root") ? antigravityRoots(given) : [])) {
|
|
246
|
+
const { cwd } = antigravityContext(conversationId);
|
|
247
|
+
if (!(cwd && excluded(cwd))) yield { id: `antigravity:${conversationId}`, file };
|
|
248
|
+
}
|
|
292
249
|
}
|
|
293
250
|
/** A transcript's lines as objects, whichever client wrote it; null when the file cannot be read. */
|
|
294
251
|
const parsedLines = (file) => {
|
|
252
|
+
if (DATABASE_SESSION.test(file)) return databaseLines(file);
|
|
295
253
|
let lines; try { lines = readFileSync(file, "utf8").split("\n"); } catch { return null; }
|
|
296
254
|
if (CODEX_ROLLOUT.test(file)) return [...codexLines(lines)];
|
|
255
|
+
if (ANTIGRAVITY_TRANSCRIPT.test(file)) { const id = file.split("/").at(-4); return [...antigravityLines(lines, { id, ...antigravityContext(id) })]; }
|
|
297
256
|
const out = [];
|
|
298
257
|
for (const raw of lines) { if (!raw.trim()) continue; try { out.push(JSON.parse(raw)); } catch { /* a torn line */ } }
|
|
299
258
|
return out;
|
|
@@ -365,9 +324,10 @@ function stretchesOf(file, since) {
|
|
|
365
324
|
const lines = parsedLines(file);
|
|
366
325
|
if (!lines) return [];
|
|
367
326
|
const messages = [];
|
|
368
|
-
let cwd = null;
|
|
327
|
+
let cwd = null, steered = STEERED_HERE;
|
|
369
328
|
for (const line of lines) {
|
|
370
329
|
if (typeof line.cwd === "string") cwd = line.cwd;
|
|
330
|
+
if (typeof line.steered === "string") steered = line.steered; // a client that says how it was steered (Hermes's source)
|
|
371
331
|
if (!line.timestamp || (line.type !== "user" && line.type !== "assistant")) continue;
|
|
372
332
|
const at = Date.parse(line.timestamp);
|
|
373
333
|
if (!Number.isFinite(at)) continue;
|
|
@@ -382,7 +342,7 @@ function stretchesOf(file, since) {
|
|
|
382
342
|
if (!byDay.has(day)) byDay.set(day, []);
|
|
383
343
|
byDay.get(day).push(message);
|
|
384
344
|
}
|
|
385
|
-
return [...byDay.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, ofDay]) => measure(cwd, ofDay)).filter(Boolean);
|
|
345
|
+
return [...byDay.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([day, ofDay]) => { const stretch = measure(cwd, ofDay); return stretch && { ...stretch, ref: stretchRef(file, day), steered }; }).filter(Boolean);
|
|
386
346
|
}
|
|
387
347
|
|
|
388
348
|
/**
|
|
@@ -460,6 +420,11 @@ function tokensOf(messages) {
|
|
|
460
420
|
return { tokensIn, tokensOut, cacheRead, cacheWrite, shapeKnown: counted > 0 };
|
|
461
421
|
}
|
|
462
422
|
|
|
423
|
+
// WHICH STRETCH, HOW STEERED (0.6.10): a hash of the session id (the file name) and the day, the same on any computer, so a
|
|
424
|
+
// restored backup is never counted twice; `remote` only when the session runs over SSH, otherwise nothing is said.
|
|
425
|
+
const stretchRef = (file, day) => createHash("sha256").update(`worktrust-stretch|${String(file).split(/[\\/]/).pop().replace(/\.jsonl?$/, "")}|${day}`).digest("base64url");
|
|
426
|
+
const STEERED_HERE = process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY ? "remote" : null;
|
|
427
|
+
|
|
463
428
|
/** The one payload shape this script can build. Every key is a number, a date or an enum value. */
|
|
464
429
|
function payloadFor(stretch) {
|
|
465
430
|
const cwd = stretch.cwd && existsSync(stretch.cwd) ? stretch.cwd : null;
|
|
@@ -489,6 +454,7 @@ function payloadFor(stretch) {
|
|
|
489
454
|
...(stretch.model ? { model: stretch.model } : {}),
|
|
490
455
|
...(repo ? { repo } : {}),
|
|
491
456
|
...(touched.shas.length > 0 ? { commits: touched.shas.slice(0, 50) } : {}),
|
|
457
|
+
...(stretch.ref ? { stretch_ref: stretch.ref } : {}), ...(stretch.steered ? { steered_from: stretch.steered } : {}),
|
|
492
458
|
at: new Date(stretch.to).toISOString(),
|
|
493
459
|
};
|
|
494
460
|
}
|
|
@@ -503,15 +469,16 @@ function payloadFor(stretch) {
|
|
|
503
469
|
async function sendHistory(door, entries, announce = false) {
|
|
504
470
|
let accepted = 0;
|
|
505
471
|
// A Codex entry goes through Codex's own coupling where one exists (see codexDoor), the rest
|
|
506
|
-
// through the door given: the record files a line under the coupling that sent it.
|
|
507
|
-
|
|
472
|
+
// through the door given: the record files a line under the coupling that sent it. Antigravity's
|
|
473
|
+
// go apart too, so their batch can name the client (clientOf).
|
|
474
|
+
for (const [source, group] of [["claude", entries.filter((entry) => !entrySource.has(entry))], ["codex", entries.filter((entry) => entrySource.get(entry) === "codex")], ...["antigravity", ...DATABASE_CLIENTS].map((client) => [client, entries.filter((entry) => entrySource.get(entry) === client)])]) {
|
|
508
475
|
const target = source === "codex" ? codexDoor ?? door : door;
|
|
509
476
|
if (group.length === 0 || !target) continue;
|
|
510
477
|
for (let i = 0; i < group.length; i += 200) {
|
|
511
478
|
const batch = group.slice(i, i + 200);
|
|
512
479
|
// History carries no shas: an imported line forms no session, and the join is the live route's.
|
|
513
480
|
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; }) } } });
|
|
514
|
-
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 });
|
|
481
|
+
const response = await fetch(target.url, { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${target.token}`, ...clientOf(source), ...proofFor(target.url, body) }, body });
|
|
515
482
|
const text = await response.text();
|
|
516
483
|
if (announce) console.log(`batch ${Math.floor(i / 200) + 1}: ${response.status} ${text.slice(0, 200).replace(/\\n/g, " ")}`);
|
|
517
484
|
if (!response.ok) break;
|
|
@@ -530,9 +497,16 @@ const proofFor = (url, body) => {
|
|
|
530
497
|
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")}` };
|
|
531
498
|
};
|
|
532
499
|
|
|
500
|
+
/**
|
|
501
|
+
* WHICH CLIENT A LINE CAME FROM, FOR THE DOOR (2026-10-04). Through the bridge one key serves every app
|
|
502
|
+
* on this computer, and the door names a coupling's clients by the user-agent it hears (mcp_identify's
|
|
503
|
+
* agents_seen). An Antigravity line names Antigravity: the product name, nothing of the work.
|
|
504
|
+
*/
|
|
505
|
+
const clientOf = (source) => (source === "antigravity" || DATABASE_CLIENTS.includes(source) ? { "user-agent": `${source}/hook (worktrust-hook)` } : {});
|
|
506
|
+
|
|
533
507
|
async function send(door, payload) {
|
|
534
508
|
const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "log_work", arguments: payload } });
|
|
535
|
-
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 });
|
|
509
|
+
const response = await fetch(door.url, { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${door.token}`, ...clientOf(entrySource.get(payload)), ...proofFor(door.url, body) }, body });
|
|
536
510
|
if (!response.ok) throw new Error(`door said ${response.status}`);
|
|
537
511
|
return response.text();
|
|
538
512
|
}
|
|
@@ -551,15 +525,16 @@ async function send(door, payload) {
|
|
|
551
525
|
* matches commits by, and cutting on the calendar keeps `at` a real instant rather than an
|
|
552
526
|
* average of one. Younger than the live window is left alone — that is `log_work`'s half.
|
|
553
527
|
*/
|
|
554
|
-
function historyEntries() {
|
|
528
|
+
function historyEntries(floor = null) {
|
|
555
529
|
const entries = [];
|
|
556
530
|
for (const { file } of allTranscripts()) {
|
|
557
531
|
const lines = parsedLines(file);
|
|
558
532
|
if (!lines) continue;
|
|
559
|
-
let cwd = null;
|
|
533
|
+
let cwd = null, steered = null;
|
|
560
534
|
const byDay = new Map();
|
|
561
535
|
for (const line of lines) {
|
|
562
536
|
if (typeof line.cwd === "string") cwd = line.cwd;
|
|
537
|
+
if (typeof line.steered === "string") steered = line.steered;
|
|
563
538
|
if (!line.timestamp || (line.type !== "user" && line.type !== "assistant")) continue;
|
|
564
539
|
const at = Date.parse(line.timestamp);
|
|
565
540
|
if (!Number.isFinite(at)) continue;
|
|
@@ -570,6 +545,8 @@ function historyEntries() {
|
|
|
570
545
|
for (const [day, messages] of byDay) {
|
|
571
546
|
if (messages.length === 0) continue;
|
|
572
547
|
messages.sort((a, b) => a.at - b.at);
|
|
548
|
+
// Under-reporting is the safe side (THE FLOORS): a day that began before the account changed is not offered at all.
|
|
549
|
+
if (floor !== null && messages[0].at < floor) continue;
|
|
573
550
|
const last = messages[messages.length - 1].at;
|
|
574
551
|
// The live window belongs to the hook: a stretch that log_work may still report is not history.
|
|
575
552
|
if (Date.now() - last < WINDOW_HOURS * 3600_000) continue;
|
|
@@ -585,12 +562,13 @@ function historyEntries() {
|
|
|
585
562
|
const entry = payloadFor({
|
|
586
563
|
cwd, seconds: seconds >= MIN_SECONDS ? Math.min(MAX_SECONDS, seconds) : null,
|
|
587
564
|
tokensIn, tokensOut, cacheRead, cacheWrite, shapeKnown, exchanges, model: [...models.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null,
|
|
588
|
-
from: messages[0].at, to: last,
|
|
565
|
+
from: messages[0].at, to: last, ref: stretchRef(file, day), steered,
|
|
589
566
|
});
|
|
590
567
|
if (CODEX_ROLLOUT.test(file)) entrySource.set(entry, "codex");
|
|
568
|
+
if (ANTIGRAVITY_TRANSCRIPT.test(file)) entrySource.set(entry, "antigravity");
|
|
569
|
+
if (DATABASE_SESSION.test(file)) entrySource.set(entry, file.slice(0, file.indexOf(":")));
|
|
591
570
|
entryFile.set(entry, file);
|
|
592
571
|
entries.push(entry);
|
|
593
|
-
void day;
|
|
594
572
|
}
|
|
595
573
|
}
|
|
596
574
|
return entries.sort((a, b) => a.at.localeCompare(b.at));
|
|
@@ -622,6 +600,7 @@ function* allTranscripts() {
|
|
|
622
600
|
*/
|
|
623
601
|
function* candidates() {
|
|
624
602
|
for (const rollout of codexRollouts()) {
|
|
603
|
+
if (rollout.writtenAt) { yield rollout; continue; } // a database session knows its own last moment
|
|
625
604
|
let stat; try { stat = statSync(rollout.file); } catch { continue; }
|
|
626
605
|
yield { ...rollout, writtenAt: stat.mtimeMs };
|
|
627
606
|
}
|
|
@@ -647,9 +626,10 @@ function hookInput() {
|
|
|
647
626
|
// comes, and a hook that hangs is worse than one that does nothing. A dry run READS it, so the
|
|
648
627
|
// hook path is the one a person can rehearse: `echo '<the hook JSON>' | node log-session.mjs
|
|
649
628
|
// --dry-run` shows exactly what that session would send.
|
|
650
|
-
|
|
629
|
+
// Antigravity's Stop hook hands its JSON over in the environment (worktrust.mjs hook --antigravity-stop).
|
|
630
|
+
if (process.stdin.isTTY && !process.env.WORKTRUST_HOOK_INPUT) return null;
|
|
651
631
|
try {
|
|
652
|
-
const raw = readFileSync(0, "utf8");
|
|
632
|
+
const raw = process.env.WORKTRUST_HOOK_INPUT ?? readFileSync(0, "utf8");
|
|
653
633
|
return raw.trim() ? JSON.parse(raw) : null;
|
|
654
634
|
} catch { return null; }
|
|
655
635
|
}
|
|
@@ -670,13 +650,21 @@ if (flag("uninstall")) {
|
|
|
670
650
|
}
|
|
671
651
|
const removed = before !== JSON.stringify(HOOK_EVENTS.map((event) => settings.hooks?.[event] ?? []));
|
|
672
652
|
if (removed) writeFileSync(file, JSON.stringify(settings, null, 2));
|
|
673
|
-
if (flag("purge")) for (const path of [COUNTER, PINNED, join(homedir(), ".worktrust", "log-session.mjs"), STATE]) { try { rmSync(path); } catch { /* already gone */ } }
|
|
653
|
+
if (flag("purge")) for (const path of [COUNTER, PINNED, join(homedir(), ".worktrust", "log-session.mjs"), STATE, ...READER_FILES.map((name) => join(homedir(), ".worktrust", name)), join(homedir(), ".worktrust", "antigravity.json")]) { try { rmSync(path); } catch { /* already gone */ } }
|
|
674
654
|
console.log(`${removed ? "removed" : "nothing wired"}: ${file}${flag("purge") ? "\n purged ~/.worktrust" : ""}`);
|
|
675
655
|
console.log("the coupling itself still exists: end it in the app (Sources → the computer's row → end this coupling)");
|
|
676
656
|
process.exit(0);
|
|
677
657
|
}
|
|
678
658
|
|
|
679
659
|
if (flag("install")) {
|
|
660
|
+
// `--from-now` (every new coupling) and `--history-floor now` (a coupling to another account): see THE FLOORS.
|
|
661
|
+
if (flag("from-now") || value("history-floor") === "now") {
|
|
662
|
+
const floors = readState();
|
|
663
|
+
const now = new Date().toISOString();
|
|
664
|
+
if (flag("from-now")) floors.__from = now;
|
|
665
|
+
if (value("history-floor") === "now") floors.__historyFloor = now;
|
|
666
|
+
writeState(floors);
|
|
667
|
+
}
|
|
680
668
|
// Wired to SessionEnd, not to every reply: the door asks for one call per PIECE of work, and a
|
|
681
669
|
// hook that fired per message would report a hundred stretches for one afternoon.
|
|
682
670
|
//
|
|
@@ -730,6 +718,7 @@ if (flag("install")) {
|
|
|
730
718
|
// The counting half, from the same deployment the hook came from: one command installs both, or
|
|
731
719
|
// says plainly that it could not — a half nobody knows is missing is the worse outcome.
|
|
732
720
|
const counterOrigin = coupling()?.url ? new URL(coupling().url).origin : null;
|
|
721
|
+
if (!(await installReaders(counterOrigin))) console.log("readers: transcript-readers.mjs or session-databases.mjs not found beside this file nor at the deployment; the clients they read are not read until they are");
|
|
733
722
|
if (existsSync(COUNTER)) await refreshCounter(counterOrigin, (line) => console.log(line));
|
|
734
723
|
if (!existsSync(COUNTER)) {
|
|
735
724
|
const origin = counterOrigin;
|
|
@@ -750,10 +739,10 @@ if (flag("install")) {
|
|
|
750
739
|
console.log(repaired
|
|
751
740
|
? `repaired: ${file} pointed at ${repaired}\n it now runs ${command}`
|
|
752
741
|
: `installed: ${home}\n ${file} → ${HOOK_EVENTS.join(", ")} run it\n the file you downloaded may be deleted; this copy is the one that runs`);
|
|
753
|
-
// Codex has no settings.json to wire; its notify hook is one line in config.toml,
|
|
754
|
-
//
|
|
755
|
-
// swept whenever the Claude Code hook runs.
|
|
756
|
-
if (existsSync(join(homedir(), ".codex"))) console.log(`codex: to have Codex wake this hook too, add to ~/.codex/config.toml:\n notify = ${via ? `["${process.env.WORKTRUST_NODE || process.execPath}", "${via}", "hook", "--codex-notify"]` : `["node", "${home}", "--codex-notify"]`}\n (its rollouts are swept on every run either way)`);
|
|
742
|
+
// Codex has no settings.json to wire; its notify hook is one line in config.toml, which this script
|
|
743
|
+
// never rewrites: `npx worktrust` asks and writes it (WORKTRUST_CODEX_OFFER, so no hint twice), and
|
|
744
|
+
// without it Codex rollouts are still swept whenever the Claude Code hook runs.
|
|
745
|
+
if (existsSync(join(homedir(), ".codex")) && !process.env.WORKTRUST_CODEX_OFFER) console.log(`codex: to have Codex wake this hook too, add to ~/.codex/config.toml:\n notify = ${via ? `["${process.env.WORKTRUST_NODE || process.execPath}", "${via}", "hook", "--codex-notify"]` : `["node", "${home}", "--codex-notify"]`}\n (its rollouts are swept on every run either way)`);
|
|
757
746
|
process.exit(0);
|
|
758
747
|
}
|
|
759
748
|
|
|
@@ -766,12 +755,13 @@ const door = coupling();
|
|
|
766
755
|
*/
|
|
767
756
|
const codexDoor = codexCoupling();
|
|
768
757
|
const doorFor = (entry) => (String(entry?.id ?? "").startsWith("codex:") ? codexDoor ?? door : door);
|
|
769
|
-
const entrySource = new WeakMap(); //
|
|
758
|
+
const entrySource = new WeakMap(); // an entry → "codex" or "antigravity" by the client that wrote it; the sends route and name by it
|
|
770
759
|
const entryFile = new WeakMap(); // a history entry → its transcript, so `--summary` can count sessions
|
|
771
760
|
if (!door && !codexDoor && !DRY) { process.exit(0); } // Not coupled on this machine: nothing to do, quietly.
|
|
772
761
|
|
|
773
762
|
if (flag("history")) {
|
|
774
|
-
const
|
|
763
|
+
const historyFloor = floorOf(readState(), "__historyFloor");
|
|
764
|
+
const entries = historyEntries(historyFloor);
|
|
775
765
|
const hours = entries.reduce((sum, entry) => sum + (entry.seconds ?? 0), 0) / 3600;
|
|
776
766
|
const months = [...new Set(entries.map((entry) => entry.at.slice(0, 7)))].sort();
|
|
777
767
|
// ONE LINE FOR `npx worktrust` TO ASK WITH (2026-10-03): sessions, hours, first and last month; nothing sent.
|
|
@@ -793,17 +783,33 @@ const work = [];
|
|
|
793
783
|
if (input?.transcript_path && existsSync(input.transcript_path) && !excluded(input.transcript_path)) {
|
|
794
784
|
work.push({ id: String(input.session_id ?? input.transcript_path), file: input.transcript_path });
|
|
795
785
|
}
|
|
786
|
+
// ANTIGRAVITY'S STOP (camelCase, antigravity.google/docs/hooks/): its model and folder are kept for the sweep; no text is in it.
|
|
787
|
+
if (typeof input?.transcriptPath === "string" && ANTIGRAVITY_TRANSCRIPT.test(input.transcriptPath) && existsSync(input.transcriptPath)) {
|
|
788
|
+
const id = rememberAntigravity(input);
|
|
789
|
+
const { cwd } = id ? antigravityContext(id) : {};
|
|
790
|
+
if (id && !(cwd && excluded(cwd))) work.push({ id: `antigravity:${id}`, file: input.transcriptPath });
|
|
791
|
+
}
|
|
796
792
|
// A session that ended with the terminal closed fires no hook, so every run also sweeps the
|
|
797
793
|
// transcripts that have gone quiet and were never logged. Self-healing, and it can only ever
|
|
798
794
|
// report a stretch it can measure.
|
|
799
795
|
for (const found of candidates()) if (!work.some((entry) => entry.file === found.file)) work.push(found);
|
|
800
796
|
|
|
797
|
+
// A computer coupled by an older CLI has no `__from`: it is set to the first watermark this computer
|
|
798
|
+
// ever wrote (when it first sent), or to now on a computer that has sent nothing, and kept.
|
|
799
|
+
if (floorOf(state, "__from") === null) {
|
|
800
|
+
const marks = Object.entries(state).filter(([key, entry]) => !key.startsWith("__") && entry?.through).map(([, entry]) => Date.parse(entry.through)).filter(Number.isFinite);
|
|
801
|
+
state.__from = new Date(marks.length > 0 ? Math.min(...marks) : Date.now()).toISOString();
|
|
802
|
+
if (!DRY) writeState(state);
|
|
803
|
+
}
|
|
804
|
+
const floor = floorOf(state, "__from");
|
|
801
805
|
let sent = 0;
|
|
802
806
|
let filed = 0;
|
|
803
807
|
/** Measured days the live door will no longer take. They are not dropped; they are filed. */
|
|
804
808
|
const late = [];
|
|
805
809
|
for (const entry of work) {
|
|
806
|
-
const
|
|
810
|
+
const mark = state[entry.id]?.through ? Date.parse(state[entry.id].through) : null;
|
|
811
|
+
// Never before the coupling (THE FLOORS): a transcript with no watermark starts at `__from`, not at its first line.
|
|
812
|
+
const since = mark !== null && floor !== null ? Math.max(mark, floor) : mark ?? floor;
|
|
807
813
|
// Nothing has been written since we last read this one — no need to open it at all. This is what
|
|
808
814
|
// keeps a sweep over every transcript on the machine to one `stat` each.
|
|
809
815
|
if (since !== null && entry.writtenAt && entry.writtenAt <= since) continue;
|
|
@@ -816,8 +822,9 @@ for (const entry of work) {
|
|
|
816
822
|
// every day but today, on whichever run comes first — it no longer waits for its own end.
|
|
817
823
|
if (Date.now() - stretch.to < IDLE_BEFORE_SWEEP) break;
|
|
818
824
|
const payload = payloadFor(stretch);
|
|
819
|
-
|
|
820
|
-
if (
|
|
825
|
+
const source = /^(codex|antigravity|hermes|goose|opencode|openclaw):/.exec(String(entry.id))?.[1];
|
|
826
|
+
if (source) entrySource.set(payload, source);
|
|
827
|
+
if (DRY) { console.log(JSON.stringify({ ...payload, client: source ?? "claude", route: Date.now() - stretch.to > WINDOW_HOURS * 3600_000 ? "history" : "log_work" }, null, 1)); through = stretch.to; continue; }
|
|
821
828
|
// MEASURED TIME IS NEVER DROPPED. The live door takes work as it happens and refuses anything
|
|
822
829
|
// older than two days — which is right, and used to mean a machine that was closed for a
|
|
823
830
|
// fortnight lost the fortnight. The same measured numbers go through the history door instead:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "worktrust",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.10",
|
|
4
4
|
"description": "Couple this computer to WorkTrust: approve a code in your browser, and every AI app here reports what you built. Metadata only, no dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
"setup-mcp.mjs",
|
|
12
12
|
"log-session.mjs",
|
|
13
13
|
"count-behaviour.mjs",
|
|
14
|
+
"transcript-readers.mjs",
|
|
15
|
+
"session-databases.mjs",
|
|
16
|
+
"config-edits.mjs",
|
|
14
17
|
"README.md",
|
|
15
18
|
"SECURITY.md",
|
|
16
19
|
"LICENSE"
|
|
@@ -25,6 +28,7 @@
|
|
|
25
28
|
"mcp",
|
|
26
29
|
"claude-code",
|
|
27
30
|
"codex",
|
|
31
|
+
"antigravity",
|
|
28
32
|
"cursor",
|
|
29
33
|
"ai",
|
|
30
34
|
"provenance"
|