worktrust 0.6.6 → 0.6.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -5
- package/count-behaviour.mjs +26 -97
- package/log-session.mjs +99 -112
- package/package.json +3 -1
- package/setup-mcp.mjs +40 -6
- package/transcript-readers.mjs +217 -0
- package/worktrust.mjs +275 -75
package/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,53 @@ 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
|
-
*
|
|
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 FOR CODEX AND ANTIGRAVITY live in transcript-readers.mjs beside this file (2026-10-04;
|
|
200
|
+
* the counter imports the same file, so there is one reader, not two copies). The install copies it
|
|
201
|
+
* into ~/.worktrust with this hook. Without it this hook still reads Claude Code; the never-matching
|
|
202
|
+
* patterns below keep every other client out rather than half read.
|
|
203
203
|
*/
|
|
204
|
-
const CODEX_ROLLOUT = /(
|
|
205
|
-
const
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (mapped === "Bash") {
|
|
210
|
-
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 }; }
|
|
211
|
-
return { command: Array.isArray(raw?.command) ? raw.command.join(" ") : String(raw?.command ?? raw?.cmd ?? "") };
|
|
212
|
-
}
|
|
213
|
-
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() : "" }; }
|
|
214
|
-
return {};
|
|
215
|
-
};
|
|
216
|
-
const codexOutputText = (output) => (typeof output === "string" ? output : Array.isArray(output) ? output.map((part) => (typeof part === "string" ? part : part?.text ?? "")).join("\n") : "");
|
|
217
|
-
/** The rollout's raw JSON lines → transcript-shaped line objects, in file order. */
|
|
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;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
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; }
|
|
204
|
+
const { CODEX_ROLLOUT = /(?!)/, codexLines, codexRolloutFiles = function* () {}, codexCwd = () => null, ANTIGRAVITY_TRANSCRIPT = /(?!)/, antigravityLines, antigravityRoots = () => [], antigravityTranscripts = function* () {}, antigravityContext = () => ({}), rememberAntigravity = () => null } = (await import("./transcript-readers.mjs").catch(() => null)) ?? {};
|
|
205
|
+
const READERS = join(dirname(fileURLToPath(import.meta.url)), "transcript-readers.mjs");
|
|
206
|
+
const READERS_HOME = join(homedir(), ".worktrust", "transcript-readers.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) {
|
|
270
209
|
try {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
210
|
+
if (!fromNetwork && existsSync(READERS)) { mkdirSync(dirname(READERS_HOME), { recursive: true }); if (READERS !== READERS_HOME) copyFileSync(READERS, READERS_HOME); return true; }
|
|
211
|
+
if (!origin) return false;
|
|
212
|
+
const response = await fetch(`${origin}/counter/transcript-readers.mjs`);
|
|
213
|
+
if (!response.ok) return false;
|
|
214
|
+
writeFileSync(READERS_HOME, await response.text());
|
|
215
|
+
return true;
|
|
216
|
+
} catch { return false; }
|
|
277
217
|
}
|
|
278
|
-
// ── end codex rollout reader ──
|
|
279
218
|
|
|
280
219
|
/** The roots this machine's transcripts live under; a test names its own. */
|
|
281
220
|
const claudeRoot = () => value("transcript-root") ?? join(homedir(), ".claude", "projects");
|
|
282
221
|
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. */
|
|
222
|
+
/** Every Codex rollout and Antigravity conversation on this machine, an excluded project's threads skipped by their working directory. */
|
|
284
223
|
function* codexRollouts() {
|
|
285
224
|
const root = codexRoot();
|
|
286
|
-
|
|
287
|
-
for (const file of codexRolloutFiles(root)) {
|
|
225
|
+
for (const file of root ? codexRolloutFiles(root) : []) {
|
|
288
226
|
const cwd = codexCwd(file);
|
|
289
227
|
if (cwd && excluded(cwd)) continue;
|
|
290
228
|
yield { id: `codex:${file.split("/").at(-1).replace(/\.jsonl$/, "")}`, file };
|
|
291
229
|
}
|
|
230
|
+
yield* antigravityConversations();
|
|
231
|
+
}
|
|
232
|
+
/** Antigravity beside Codex: one transcript per conversation, its folder known only from the Stop hook. */
|
|
233
|
+
function* antigravityConversations() {
|
|
234
|
+
const given = value("antigravity-root");
|
|
235
|
+
for (const { conversationId, file } of antigravityTranscripts(given || !value("transcript-root") ? antigravityRoots(given) : [])) {
|
|
236
|
+
const { cwd } = antigravityContext(conversationId);
|
|
237
|
+
if (!(cwd && excluded(cwd))) yield { id: `antigravity:${conversationId}`, file };
|
|
238
|
+
}
|
|
292
239
|
}
|
|
293
240
|
/** A transcript's lines as objects, whichever client wrote it; null when the file cannot be read. */
|
|
294
241
|
const parsedLines = (file) => {
|
|
295
242
|
let lines; try { lines = readFileSync(file, "utf8").split("\n"); } catch { return null; }
|
|
296
243
|
if (CODEX_ROLLOUT.test(file)) return [...codexLines(lines)];
|
|
244
|
+
if (ANTIGRAVITY_TRANSCRIPT.test(file)) { const id = file.split("/").at(-4); return [...antigravityLines(lines, { id, ...antigravityContext(id) })]; }
|
|
297
245
|
const out = [];
|
|
298
246
|
for (const raw of lines) { if (!raw.trim()) continue; try { out.push(JSON.parse(raw)); } catch { /* a torn line */ } }
|
|
299
247
|
return out;
|
|
@@ -503,15 +451,16 @@ function payloadFor(stretch) {
|
|
|
503
451
|
async function sendHistory(door, entries, announce = false) {
|
|
504
452
|
let accepted = 0;
|
|
505
453
|
// 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
|
-
|
|
454
|
+
// through the door given: the record files a line under the coupling that sent it. Antigravity's
|
|
455
|
+
// go apart too, so their batch can name the client (clientOf).
|
|
456
|
+
for (const [source, group] of [["claude", entries.filter((entry) => !entrySource.has(entry))], ["codex", entries.filter((entry) => entrySource.get(entry) === "codex")], ["antigravity", entries.filter((entry) => entrySource.get(entry) === "antigravity")]]) {
|
|
508
457
|
const target = source === "codex" ? codexDoor ?? door : door;
|
|
509
458
|
if (group.length === 0 || !target) continue;
|
|
510
459
|
for (let i = 0; i < group.length; i += 200) {
|
|
511
460
|
const batch = group.slice(i, i + 200);
|
|
512
461
|
// History carries no shas: an imported line forms no session, and the join is the live route's.
|
|
513
462
|
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 });
|
|
463
|
+
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
464
|
const text = await response.text();
|
|
516
465
|
if (announce) console.log(`batch ${Math.floor(i / 200) + 1}: ${response.status} ${text.slice(0, 200).replace(/\\n/g, " ")}`);
|
|
517
466
|
if (!response.ok) break;
|
|
@@ -530,9 +479,16 @@ const proofFor = (url, body) => {
|
|
|
530
479
|
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
480
|
};
|
|
532
481
|
|
|
482
|
+
/**
|
|
483
|
+
* WHICH CLIENT A LINE CAME FROM, FOR THE DOOR (2026-10-04). Through the bridge one key serves every app
|
|
484
|
+
* on this computer, and the door names a coupling's clients by the user-agent it hears (mcp_identify's
|
|
485
|
+
* agents_seen). An Antigravity line names Antigravity: the product name, nothing of the work.
|
|
486
|
+
*/
|
|
487
|
+
const clientOf = (source) => (source === "antigravity" ? { "user-agent": "antigravity/hook (worktrust-hook)" } : {});
|
|
488
|
+
|
|
533
489
|
async function send(door, payload) {
|
|
534
490
|
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 });
|
|
491
|
+
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
492
|
if (!response.ok) throw new Error(`door said ${response.status}`);
|
|
537
493
|
return response.text();
|
|
538
494
|
}
|
|
@@ -551,7 +507,7 @@ async function send(door, payload) {
|
|
|
551
507
|
* matches commits by, and cutting on the calendar keeps `at` a real instant rather than an
|
|
552
508
|
* average of one. Younger than the live window is left alone — that is `log_work`'s half.
|
|
553
509
|
*/
|
|
554
|
-
function historyEntries() {
|
|
510
|
+
function historyEntries(floor = null) {
|
|
555
511
|
const entries = [];
|
|
556
512
|
for (const { file } of allTranscripts()) {
|
|
557
513
|
const lines = parsedLines(file);
|
|
@@ -570,6 +526,8 @@ function historyEntries() {
|
|
|
570
526
|
for (const [day, messages] of byDay) {
|
|
571
527
|
if (messages.length === 0) continue;
|
|
572
528
|
messages.sort((a, b) => a.at - b.at);
|
|
529
|
+
// Under-reporting is the safe side (THE FLOORS): a day that began before the account changed is not offered at all.
|
|
530
|
+
if (floor !== null && messages[0].at < floor) continue;
|
|
573
531
|
const last = messages[messages.length - 1].at;
|
|
574
532
|
// The live window belongs to the hook: a stretch that log_work may still report is not history.
|
|
575
533
|
if (Date.now() - last < WINDOW_HOURS * 3600_000) continue;
|
|
@@ -588,6 +546,7 @@ function historyEntries() {
|
|
|
588
546
|
from: messages[0].at, to: last,
|
|
589
547
|
});
|
|
590
548
|
if (CODEX_ROLLOUT.test(file)) entrySource.set(entry, "codex");
|
|
549
|
+
if (ANTIGRAVITY_TRANSCRIPT.test(file)) entrySource.set(entry, "antigravity");
|
|
591
550
|
entryFile.set(entry, file);
|
|
592
551
|
entries.push(entry);
|
|
593
552
|
void day;
|
|
@@ -647,9 +606,10 @@ function hookInput() {
|
|
|
647
606
|
// comes, and a hook that hangs is worse than one that does nothing. A dry run READS it, so the
|
|
648
607
|
// hook path is the one a person can rehearse: `echo '<the hook JSON>' | node log-session.mjs
|
|
649
608
|
// --dry-run` shows exactly what that session would send.
|
|
650
|
-
|
|
609
|
+
// Antigravity's Stop hook hands its JSON over in the environment (worktrust.mjs hook --antigravity-stop).
|
|
610
|
+
if (process.stdin.isTTY && !process.env.WORKTRUST_HOOK_INPUT) return null;
|
|
651
611
|
try {
|
|
652
|
-
const raw = readFileSync(0, "utf8");
|
|
612
|
+
const raw = process.env.WORKTRUST_HOOK_INPUT ?? readFileSync(0, "utf8");
|
|
653
613
|
return raw.trim() ? JSON.parse(raw) : null;
|
|
654
614
|
} catch { return null; }
|
|
655
615
|
}
|
|
@@ -670,13 +630,21 @@ if (flag("uninstall")) {
|
|
|
670
630
|
}
|
|
671
631
|
const removed = before !== JSON.stringify(HOOK_EVENTS.map((event) => settings.hooks?.[event] ?? []));
|
|
672
632
|
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 */ } }
|
|
633
|
+
if (flag("purge")) for (const path of [COUNTER, PINNED, join(homedir(), ".worktrust", "log-session.mjs"), STATE, READERS_HOME, join(homedir(), ".worktrust", "antigravity.json")]) { try { rmSync(path); } catch { /* already gone */ } }
|
|
674
634
|
console.log(`${removed ? "removed" : "nothing wired"}: ${file}${flag("purge") ? "\n purged ~/.worktrust" : ""}`);
|
|
675
635
|
console.log("the coupling itself still exists: end it in the app (Sources → the computer's row → end this coupling)");
|
|
676
636
|
process.exit(0);
|
|
677
637
|
}
|
|
678
638
|
|
|
679
639
|
if (flag("install")) {
|
|
640
|
+
// `--from-now` (every new coupling) and `--history-floor now` (a coupling to another account): see THE FLOORS.
|
|
641
|
+
if (flag("from-now") || value("history-floor") === "now") {
|
|
642
|
+
const floors = readState();
|
|
643
|
+
const now = new Date().toISOString();
|
|
644
|
+
if (flag("from-now")) floors.__from = now;
|
|
645
|
+
if (value("history-floor") === "now") floors.__historyFloor = now;
|
|
646
|
+
writeState(floors);
|
|
647
|
+
}
|
|
680
648
|
// Wired to SessionEnd, not to every reply: the door asks for one call per PIECE of work, and a
|
|
681
649
|
// hook that fired per message would report a hundred stretches for one afternoon.
|
|
682
650
|
//
|
|
@@ -730,6 +698,7 @@ if (flag("install")) {
|
|
|
730
698
|
// The counting half, from the same deployment the hook came from: one command installs both, or
|
|
731
699
|
// says plainly that it could not — a half nobody knows is missing is the worse outcome.
|
|
732
700
|
const counterOrigin = coupling()?.url ? new URL(coupling().url).origin : null;
|
|
701
|
+
if (!(await installReaders(counterOrigin))) console.log("readers: transcript-readers.mjs not found beside this file nor at the deployment; Codex and Antigravity are not read until it is");
|
|
733
702
|
if (existsSync(COUNTER)) await refreshCounter(counterOrigin, (line) => console.log(line));
|
|
734
703
|
if (!existsSync(COUNTER)) {
|
|
735
704
|
const origin = counterOrigin;
|
|
@@ -750,10 +719,10 @@ if (flag("install")) {
|
|
|
750
719
|
console.log(repaired
|
|
751
720
|
? `repaired: ${file} pointed at ${repaired}\n it now runs ${command}`
|
|
752
721
|
: `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)`);
|
|
722
|
+
// Codex has no settings.json to wire; its notify hook is one line in config.toml, which this script
|
|
723
|
+
// never rewrites: `npx worktrust` asks and writes it (WORKTRUST_CODEX_OFFER, so no hint twice), and
|
|
724
|
+
// without it Codex rollouts are still swept whenever the Claude Code hook runs.
|
|
725
|
+
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
726
|
process.exit(0);
|
|
758
727
|
}
|
|
759
728
|
|
|
@@ -766,12 +735,13 @@ const door = coupling();
|
|
|
766
735
|
*/
|
|
767
736
|
const codexDoor = codexCoupling();
|
|
768
737
|
const doorFor = (entry) => (String(entry?.id ?? "").startsWith("codex:") ? codexDoor ?? door : door);
|
|
769
|
-
const entrySource = new WeakMap(); //
|
|
738
|
+
const entrySource = new WeakMap(); // an entry → "codex" or "antigravity" by the client that wrote it; the sends route and name by it
|
|
770
739
|
const entryFile = new WeakMap(); // a history entry → its transcript, so `--summary` can count sessions
|
|
771
740
|
if (!door && !codexDoor && !DRY) { process.exit(0); } // Not coupled on this machine: nothing to do, quietly.
|
|
772
741
|
|
|
773
742
|
if (flag("history")) {
|
|
774
|
-
const
|
|
743
|
+
const historyFloor = floorOf(readState(), "__historyFloor");
|
|
744
|
+
const entries = historyEntries(historyFloor);
|
|
775
745
|
const hours = entries.reduce((sum, entry) => sum + (entry.seconds ?? 0), 0) / 3600;
|
|
776
746
|
const months = [...new Set(entries.map((entry) => entry.at.slice(0, 7)))].sort();
|
|
777
747
|
// ONE LINE FOR `npx worktrust` TO ASK WITH (2026-10-03): sessions, hours, first and last month; nothing sent.
|
|
@@ -793,17 +763,33 @@ const work = [];
|
|
|
793
763
|
if (input?.transcript_path && existsSync(input.transcript_path) && !excluded(input.transcript_path)) {
|
|
794
764
|
work.push({ id: String(input.session_id ?? input.transcript_path), file: input.transcript_path });
|
|
795
765
|
}
|
|
766
|
+
// ANTIGRAVITY'S STOP (camelCase, antigravity.google/docs/hooks/): its model and folder are kept for the sweep; no text is in it.
|
|
767
|
+
if (typeof input?.transcriptPath === "string" && ANTIGRAVITY_TRANSCRIPT.test(input.transcriptPath) && existsSync(input.transcriptPath)) {
|
|
768
|
+
const id = rememberAntigravity(input);
|
|
769
|
+
const { cwd } = id ? antigravityContext(id) : {};
|
|
770
|
+
if (id && !(cwd && excluded(cwd))) work.push({ id: `antigravity:${id}`, file: input.transcriptPath });
|
|
771
|
+
}
|
|
796
772
|
// A session that ended with the terminal closed fires no hook, so every run also sweeps the
|
|
797
773
|
// transcripts that have gone quiet and were never logged. Self-healing, and it can only ever
|
|
798
774
|
// report a stretch it can measure.
|
|
799
775
|
for (const found of candidates()) if (!work.some((entry) => entry.file === found.file)) work.push(found);
|
|
800
776
|
|
|
777
|
+
// A computer coupled by an older CLI has no `__from`: it is set to the first watermark this computer
|
|
778
|
+
// ever wrote (when it first sent), or to now on a computer that has sent nothing, and kept.
|
|
779
|
+
if (floorOf(state, "__from") === null) {
|
|
780
|
+
const marks = Object.entries(state).filter(([key, entry]) => !key.startsWith("__") && entry?.through).map(([, entry]) => Date.parse(entry.through)).filter(Number.isFinite);
|
|
781
|
+
state.__from = new Date(marks.length > 0 ? Math.min(...marks) : Date.now()).toISOString();
|
|
782
|
+
if (!DRY) writeState(state);
|
|
783
|
+
}
|
|
784
|
+
const floor = floorOf(state, "__from");
|
|
801
785
|
let sent = 0;
|
|
802
786
|
let filed = 0;
|
|
803
787
|
/** Measured days the live door will no longer take. They are not dropped; they are filed. */
|
|
804
788
|
const late = [];
|
|
805
789
|
for (const entry of work) {
|
|
806
|
-
const
|
|
790
|
+
const mark = state[entry.id]?.through ? Date.parse(state[entry.id].through) : null;
|
|
791
|
+
// Never before the coupling (THE FLOORS): a transcript with no watermark starts at `__from`, not at its first line.
|
|
792
|
+
const since = mark !== null && floor !== null ? Math.max(mark, floor) : mark ?? floor;
|
|
807
793
|
// Nothing has been written since we last read this one — no need to open it at all. This is what
|
|
808
794
|
// keeps a sweep over every transcript on the machine to one `stat` each.
|
|
809
795
|
if (since !== null && entry.writtenAt && entry.writtenAt <= since) continue;
|
|
@@ -816,8 +802,9 @@ for (const entry of work) {
|
|
|
816
802
|
// every day but today, on whichever run comes first — it no longer waits for its own end.
|
|
817
803
|
if (Date.now() - stretch.to < IDLE_BEFORE_SWEEP) break;
|
|
818
804
|
const payload = payloadFor(stretch);
|
|
819
|
-
|
|
820
|
-
if (
|
|
805
|
+
const source = /^(codex|antigravity):/.exec(String(entry.id))?.[1];
|
|
806
|
+
if (source) entrySource.set(payload, source);
|
|
807
|
+
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
808
|
// MEASURED TIME IS NEVER DROPPED. The live door takes work as it happens and refuses anything
|
|
822
809
|
// older than two days — which is right, and used to mean a machine that was closed for a
|
|
823
810
|
// 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.9",
|
|
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,7 @@
|
|
|
11
11
|
"setup-mcp.mjs",
|
|
12
12
|
"log-session.mjs",
|
|
13
13
|
"count-behaviour.mjs",
|
|
14
|
+
"transcript-readers.mjs",
|
|
14
15
|
"README.md",
|
|
15
16
|
"SECURITY.md",
|
|
16
17
|
"LICENSE"
|
|
@@ -25,6 +26,7 @@
|
|
|
25
26
|
"mcp",
|
|
26
27
|
"claude-code",
|
|
27
28
|
"codex",
|
|
29
|
+
"antigravity",
|
|
28
30
|
"cursor",
|
|
29
31
|
"ai",
|
|
30
32
|
"provenance"
|
package/setup-mcp.mjs
CHANGED
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
* node setup-mcp.mjs --remove --write (takes it out again)
|
|
9
9
|
*
|
|
10
10
|
* One machine, one token, many clients: Claude Code, Cursor, Codex, Gemini CLI, VS Code's
|
|
11
|
-
* Copilot agent mode, Windsurf. Each keeps its own config file in its own shape; this finds
|
|
11
|
+
* Copilot agent mode, Windsurf, Mistral Vibe, Google Antigravity, Grok Build, Zed, Cline. Each keeps its own config file in its own shape; this finds
|
|
12
12
|
* the ones that exist here and adds the same WorkTrust server to each, so a coupling made once
|
|
13
13
|
* counts everywhere — and every client names itself when it first speaks, which is how Sources
|
|
14
14
|
* shows each product as coupled. Without --write nothing is touched. The token is shown
|
|
15
15
|
* masked; it lives only in the files it is written to. A config that already names the server
|
|
16
16
|
* is left as it is.
|
|
17
17
|
*/
|
|
18
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
19
19
|
import { spawnSync } from "node:child_process";
|
|
20
20
|
import { homedir, platform } from "node:os";
|
|
21
21
|
import { dirname, join } from "node:path";
|
|
@@ -68,7 +68,11 @@ const stdio = { command: NODE, args: [BRIDGE ?? "", "mcp"] };
|
|
|
68
68
|
const home = homedir();
|
|
69
69
|
/** Whether Claude Code's own command is on this machine: asked of the shell, never guessed from a file. */
|
|
70
70
|
const claudeOnPath = () => spawnSync(platform() === "win32" ? "where" : "which", ["claude"], { encoding: "utf8", shell: platform() === "win32" }).status === 0;
|
|
71
|
+
const VIBE_DIR = process.env.VIBE_HOME?.trim() || join(home, ".vibe");
|
|
72
|
+
const GROK_DIR = process.env.GROK_HOME?.trim() || join(home, ".grok");
|
|
73
|
+
const ZED_DIR = platform() === "win32" ? join(process.env.APPDATA ?? home, "Zed") : join(home, ".config", "zed");
|
|
71
74
|
const vscodeUser = platform() === "darwin" ? join(home, "Library", "Application Support", "Code", "User") : platform() === "win32" ? join(process.env.APPDATA ?? home, "Code", "User") : join(home, ".config", "Code", "User");
|
|
75
|
+
const CLINE_DIR = join(vscodeUser, "globalStorage", "saoudrizwan.claude-dev", "settings");
|
|
72
76
|
|
|
73
77
|
/** Each client: where it lives, how it says "an HTTP MCP server with a header". */
|
|
74
78
|
const CLIENTS = [
|
|
@@ -77,6 +81,21 @@ const CLIENTS = [
|
|
|
77
81
|
{ key: "gemini", label: "Gemini CLI", present: () => existsSync(join(home, ".gemini")), file: join(home, ".gemini", "settings.json"), shape: "json", root: "mcpServers", entry: BRIDGE ? stdio : { httpUrl: URL_, headers: { Authorization: `Bearer ${TOKEN}` } } },
|
|
78
82
|
{ key: "copilot", label: "VS Code (Copilot agent mode)", present: () => existsSync(vscodeUser), file: join(vscodeUser, "mcp.json"), shape: "json", root: "servers", entry: BRIDGE ? { type: "stdio", ...stdio } : { type: "http", url: URL_, headers: { Authorization: `Bearer ${TOKEN}` } } },
|
|
79
83
|
{ key: "windsurf", label: "Windsurf", present: () => existsSync(join(home, ".codeium", "windsurf")), file: join(home, ".codeium", "windsurf", "mcp_config.json"), shape: "json", root: "mcpServers", entry: BRIDGE ? stdio : { serverUrl: URL_, headers: { Authorization: `Bearer ${TOKEN}` } } },
|
|
84
|
+
// MISTRAL VIBE (2026-10-04, docs.mistral.ai/vibe/code/cli/mcp-servers): `[[mcp_servers]]` tables in
|
|
85
|
+
// ~/.vibe/config.toml (or $VIBE_HOME), each with a name, a transport and a url or a command.
|
|
86
|
+
{ key: "vibe", label: "Mistral Vibe", present: () => existsSync(VIBE_DIR), file: join(VIBE_DIR, "config.toml"), shape: "vibe", block: BRIDGE ? `\n[[mcp_servers]]\nname = "${NAME}"\ntransport = "stdio"\ncommand = ${JSON.stringify(NODE)}\nargs = [${JSON.stringify(BRIDGE)}, "mcp"]\n` : `\n[[mcp_servers]]\nname = "${NAME}"\ntransport = "streamable-http"\nurl = "${URL_}"\nheaders = { "Authorization" = "Bearer ${TOKEN}" }\n` },
|
|
87
|
+
// GOOGLE ANTIGRAVITY (2026-10-04, antigravity.google/docs/mcp/): the 2.0 app, the `agy` CLI and the IDE read
|
|
88
|
+
// ~/.gemini/config/mcp_config.json; a remote server is `serverUrl` with headers (`url` and `httpUrl` are not
|
|
89
|
+
// read), a local one `command` and `args`. Present when its config folder or one of its data folders is here.
|
|
90
|
+
{ key: "antigravity", label: "Antigravity", present: () => existsSync(join(home, ".gemini", "config")) || (() => { try { return readdirSync(join(home, ".gemini")).some((name) => /^antigravity/.test(name)); } catch { return false; } })(), file: join(home, ".gemini", "config", "mcp_config.json"), shape: "json", root: "mcpServers", entry: BRIDGE ? stdio : { serverUrl: URL_, headers: { Authorization: `Bearer ${TOKEN}` } } },
|
|
91
|
+
// GROK BUILD (2026-10-04, docs.x.ai/build/settings/reference): `[mcp_servers.<name>]` in ~/.grok/config.toml (or
|
|
92
|
+
// $GROK_HOME), `url` and `headers` for a remote server, `command` and `args` for a local one.
|
|
93
|
+
{ key: "grok", label: "Grok Build", present: () => existsSync(GROK_DIR), file: join(GROK_DIR, "config.toml"), shape: "toml", block: BRIDGE ? `\n[mcp_servers.${NAME}]\ncommand = ${JSON.stringify(NODE)}\nargs = [${JSON.stringify(BRIDGE)}, "mcp"]\n` : `\n[mcp_servers.${NAME}]\nurl = "${URL_}"\nheaders = { Authorization = "Bearer ${TOKEN}" }\n` },
|
|
94
|
+
// ZED (zed.dev/docs/ai/mcp): `context_servers` in its settings.json, `url` and `headers` for a remote server.
|
|
95
|
+
{ key: "zed", label: "Zed", present: () => existsSync(ZED_DIR), file: join(ZED_DIR, "settings.json"), shape: "json", root: "context_servers", entry: BRIDGE ? { ...stdio, env: {} } : { url: URL_, headers: { Authorization: `Bearer ${TOKEN}` } } },
|
|
96
|
+
// CLINE (docs.cline.bot/mcp): `mcpServers` in the extension's cline_mcp_settings.json; `type` must be
|
|
97
|
+
// "streamableHttp" exactly, or Cline falls back to SSE and the door answers 405.
|
|
98
|
+
{ key: "cline", label: "Cline (VS Code)", present: () => existsSync(CLINE_DIR), file: join(CLINE_DIR, "cline_mcp_settings.json"), shape: "json", root: "mcpServers", entry: BRIDGE ? { ...stdio, disabled: false } : { type: "streamableHttp", url: URL_, headers: { Authorization: `Bearer ${TOKEN}` }, disabled: false } },
|
|
80
99
|
{ key: "claude", label: "Claude Code", present: () => existsSync(join(home, ".claude.json")), file: join(home, ".claude.json"), shape: "claude" },
|
|
81
100
|
];
|
|
82
101
|
|
|
@@ -121,7 +140,8 @@ for (const client of CLIENTS) {
|
|
|
121
140
|
if (!already) { console.log(` – ${client.label}: does not name the door`); continue; }
|
|
122
141
|
if (client.shape === "json") {
|
|
123
142
|
let config = {};
|
|
124
|
-
|
|
143
|
+
// A file that does not parse (comments, a trailing comma: Zed and VS Code allow both) is never rewritten.
|
|
144
|
+
try { config = JSON.parse(readFileSync(client.file, "utf8")); } catch { console.log(` ! ${client.label}: ${client.file} is not plain JSON; take the "${NAME}" entry out by hand`); continue; }
|
|
125
145
|
const root = config[client.root] ?? {};
|
|
126
146
|
for (const [key, entry] of Object.entries(root)) {
|
|
127
147
|
const named = JSON.stringify(entry ?? {}).includes(URL_) || /worktrust\.mjs/.test(JSON.stringify(entry ?? {}));
|
|
@@ -130,6 +150,13 @@ for (const client of CLIENTS) {
|
|
|
130
150
|
config[client.root] = root;
|
|
131
151
|
console.log(` − ${client.label}: ${WRITE ? "removed from" : "would remove from"} ${client.file}`);
|
|
132
152
|
if (WRITE) writeFileSync(client.file, JSON.stringify(config, null, 2) + "\n");
|
|
153
|
+
} else if (client.shape === "vibe") {
|
|
154
|
+
// Vibe's servers are an ARRAY of tables, so a block is found by what it says, not by its header: every
|
|
155
|
+
// `[[mcp_servers]]` that names ours, the door, or the bridge goes, up to the next header at a line start.
|
|
156
|
+
const text = readFileSync(client.file, "utf8");
|
|
157
|
+
const stripped = text.replace(/\n?\[\[mcp_servers\]\][\s\S]*?(?=\n\[|$)/g, (block) => (block.includes(`name = "${NAME}"`) || block.includes(URL_) || /worktrust\.mjs/.test(block) ? "" : block));
|
|
158
|
+
console.log(` − ${client.label}: ${WRITE ? "removed from" : "would remove from"} ${client.file}`);
|
|
159
|
+
if (WRITE) writeFileSync(client.file, stripped);
|
|
133
160
|
} else if (client.shape === "toml") {
|
|
134
161
|
const text = readFileSync(client.file, "utf8");
|
|
135
162
|
// The block and nothing after it: from its own header to the next one, or the end.
|
|
@@ -146,12 +173,19 @@ for (const client of CLIENTS) {
|
|
|
146
173
|
if (already) { console.log(` ✓ ${client.label}: already names the door (${client.file})`); standing += 1; continue; }
|
|
147
174
|
if (client.shape === "json") {
|
|
148
175
|
let config = {};
|
|
149
|
-
|
|
176
|
+
// NEVER OVERWRITE WHAT DOES NOT PARSE (2026-10-04): Zed's and VS Code's settings allow comments and trailing
|
|
177
|
+
// commas, and reading such a file as empty would write the person's settings away. An empty or absent file is
|
|
178
|
+
// a new one; anything else that fails to parse is left alone and the entry is printed to add by hand.
|
|
179
|
+
const raw = existsSync(client.file) ? readFileSync(client.file, "utf8") : "";
|
|
180
|
+
try { config = raw.trim() ? JSON.parse(raw) : {}; } catch {
|
|
181
|
+
console.log(` ! ${client.label}: ${client.file} is not plain JSON (comments?), so it is left as it is. Add this under "${client.root}" yourself:\n "${NAME}": ${JSON.stringify(client.entry).replace(/Bearer [^"]+/, "Bearer <token>")}`);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
150
184
|
config[client.root] = { ...(config[client.root] ?? {}), [NAME]: client.entry };
|
|
151
185
|
console.log(` + ${client.label}: ${WRITE ? "wrote" : "would write"} ${client.file} → ${client.root}.${NAME}`);
|
|
152
186
|
if (WRITE) { mkdirSync(dirname(client.file), { recursive: true }); writeFileSync(client.file, JSON.stringify(config, null, 2) + "\n"); touched += 1; }
|
|
153
|
-
} else if (client.shape === "toml") {
|
|
154
|
-
console.log(` + ${client.label}: ${WRITE ? "appended to" : "would append to"} ${client.file} → [mcp_servers.${NAME}]`);
|
|
187
|
+
} else if (client.shape === "toml" || client.shape === "vibe") {
|
|
188
|
+
console.log(` + ${client.label}: ${WRITE ? "appended to" : "would append to"} ${client.file} → ${client.shape === "vibe" ? `[[mcp_servers]] name = "${NAME}"` : `[mcp_servers.${NAME}]`}`);
|
|
155
189
|
if (WRITE) { mkdirSync(dirname(client.file), { recursive: true }); writeFileSync(client.file, (existsSync(client.file) ? readFileSync(client.file, "utf8") : "") + client.block); touched += 1; }
|
|
156
190
|
}
|
|
157
191
|
}
|