worktrust 0.6.14 → 0.6.16
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 +4 -4
- package/config-edits.mjs +15 -0
- package/log-session.mjs +37 -37
- package/package.json +1 -1
- package/preserve-summary.mjs +3 -3
- package/preserve.mjs +3 -3
- package/session-databases.mjs +10 -10
- package/setup-mcp.mjs +3 -3
- package/transcript-readers.mjs +7 -2
- package/worktrust.mjs +7 -4
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ yourself run a command that sends. See [Keep your history](#keep-your-history).
|
|
|
38
38
|
3. **The terminal finishes by itself.** It keeps the key in its private file and gives every
|
|
39
39
|
AI app here the WorkTrust door, then installs the session hook. Quit your AI apps and open them
|
|
40
40
|
again.
|
|
41
|
-
4. **Earlier work only if you say yes.** It counts
|
|
41
|
+
4. **Earlier work only if you say yes.** It counts your AI apps' sessions already on
|
|
42
42
|
this computer and asks `Send these as history? [y/N]`. Enter sends nothing. What goes is hours
|
|
43
43
|
and tokens per day, never text, and WorkTrust shows it as earlier work, never as verified hours.
|
|
44
44
|
Nothing from before the coupling is ever sent without that yes: the session hook starts at the
|
|
@@ -202,11 +202,11 @@ which would delete every password on the computer, and many people rightly keep
|
|
|
202
202
|
|
|
203
203
|
| | |
|
|
204
204
|
|---|---|
|
|
205
|
-
| **Reads** | Which AI apps are installed (the settings folders of Claude Code, Codex, Cursor, Gemini CLI, Antigravity, VS Code, Windsurf, Hermes Agent, Goose, OpenCode, OpenClaw), and the chats Cursor and Copilot keep (clocks, models and token counts only). Afterwards, the session hook reads Claude Code's, Codex's and Antigravity's session files
|
|
205
|
+
| **Reads** | Which AI apps are installed (the settings folders of Claude Code, Codex, Cursor, Gemini CLI, Antigravity, VS Code, Windsurf, Hermes Agent, Goose, OpenCode, OpenClaw), and the chats Cursor and Copilot keep (clocks, models and token counts only). Afterwards, the session hook reads Claude Code's, Codex's and Antigravity's session files, the session databases of Hermes, Goose, OpenCode and OpenClaw, and the chats Cursor and Copilot keep, on this computer to measure durations and token counts. |
|
|
206
206
|
| **Writes** | The key file (above). In each AI app's MCP settings (`~/.claude.json` through `claude mcp`, `~/.cursor/mcp.json`, `~/.codex/config.toml`, `~/.gemini/settings.json`, `~/.gemini/config/mcp_config.json` (Antigravity), VS Code's `mcp.json`, Windsurf's `mcp_config.json`, Hermes's and Goose's `config.yaml`, OpenCode's `opencode.json`, OpenClaw through `openclaw mcp set`) a WorkTrust entry that runs the local bridge, with no key in it; three hooks in `~/.claude/settings.json`; the bridge, the hook and the counter in `~/.worktrust/`. |
|
|
207
207
|
| **Sends, to pair** | This computer's system (macOS, Windows, Linux) and host name. WorkTrust also records the network address the request arrives from, as a security record kept 90 days and seen only by WorkTrust staff (to answer a theft or fraud report). |
|
|
208
|
-
| **Sends, afterwards** |
|
|
209
|
-
| **Never sends** | Prompts, answers, code, file names or paths, commit messages, branch or
|
|
208
|
+
| **Sends, afterwards** | Per measured stretch of work, and nothing else: its durations (`seconds`, `duration_basis`, `model_seconds`, `tool_seconds`, `human_seconds`, `idle_seconds`), token counts (`tokens_in`, `tokens_out`, `tokens_cache_read`, `tokens_cache_write`, `token_basis`), the model's name (`model`), the kind of work and one layer keyword such as frontend or backend (`title`, `kind`, `layer`, `layers`), counts of how you work (`exchanges`, `turn_basis`, `agent_runs`, `agent_seconds`, `agent_peak`), when it ended (`at`), a hash of the session and the day (`stretch_ref`), `steered_from` when it ran over SSH, and, so the work can be matched to GitHub, the repository as its remote's owner/name (`repo`) and the hashes of the commits made in the stretch (`commits`, never their messages). |
|
|
209
|
+
| **Never sends** | Prompts, answers, code, file names or paths, commit messages, branch names, or the name of your local folder (a repository is named only as its remote's owner/name, above). The door has no field for them and refuses a submission that carries them. |
|
|
210
210
|
|
|
211
211
|
## Why you can check it
|
|
212
212
|
|
package/config-edits.mjs
CHANGED
|
@@ -67,3 +67,18 @@ export function takeOut(lines, path, mine) {
|
|
|
67
67
|
if (left && !lines.slice(left.at + 1, left.end).some(meaningful)) lines.splice(left.at, left.end - left.at);
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* ONLY THE HOUSE'S OWN DOOR (0.6.16, audit L5; the same rule as log-session.mjs and count-behaviour.mjs): an MCP address is
|
|
73
|
+
* WorkTrust's when its path is /api/mcp and its host is worktrust.io or a subdomain over https, the host WORKTRUST_MCP_URL
|
|
74
|
+
* names, or this computer. `/api/mcp` alone is every hosted MCP server's conventional path, so another product's entry is
|
|
75
|
+
* never adopted as the coupling, and never taken out with it.
|
|
76
|
+
*/
|
|
77
|
+
export const isWorkTrustDoor = (url) => {
|
|
78
|
+
try {
|
|
79
|
+
const parsed = new URL(String(url));
|
|
80
|
+
if (!/\/api\/mcp\/?$/.test(parsed.pathname)) return false;
|
|
81
|
+
const own = process.env.WORKTRUST_MCP_URL ? new URL(process.env.WORKTRUST_MCP_URL).host : null;
|
|
82
|
+
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";
|
|
83
|
+
} catch { return false; }
|
|
84
|
+
};
|
package/log-session.mjs
CHANGED
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
* `worktrust.mjs hook --antigravity-stop`. The readers live in transcript-readers.mjs beside this file.
|
|
39
39
|
*
|
|
40
40
|
* WHAT CANNOT LEAVE. The payload is built from a fixed allowlist of keys, all of them numbers,
|
|
41
|
-
* dates or vocabulary from the door's own enums
|
|
42
|
-
* message, no branch, no
|
|
41
|
+
* dates or vocabulary from the door's own enums, and the repository as owner/name and commit hashes. No prompt, no
|
|
42
|
+
* answer, no file path, no commit message, no branch, no folder name. File paths ARE read locally to decide one layer keyword,
|
|
43
43
|
* the way the connector reads them to decide an activity class, and only the keyword travels.
|
|
44
44
|
*
|
|
45
45
|
* WHAT IS MEASURED, AND WHAT IS THEREFORE NOT SENT. A stretch's seconds are the sum of the gaps
|
|
@@ -209,9 +209,10 @@ const excluded = (path) => excludes.some((text) => text && path.includes(text));
|
|
|
209
209
|
* reader), the database clients in session-databases.mjs. The install copies both into ~/.worktrust with this hook.
|
|
210
210
|
* Without them this hook still reads Claude Code; the never-matching patterns keep every other client out.
|
|
211
211
|
*/
|
|
212
|
-
const { CODEX_ROLLOUT = /(?!)/, codexLines, codexRolloutFiles = function* () {}, codexCwd = () => null, ANTIGRAVITY_TRANSCRIPT = /(?!)/, antigravityLines, antigravityRoots = () => [], antigravityTranscripts = function* () {}, antigravityContext = () => ({}), rememberAntigravity = () => null } = (await import("./transcript-readers.mjs").catch(() => null)) ?? {};
|
|
212
|
+
const { CODEX_ROLLOUT = /(?!)/, codexIdOf = (file) => String(file).split(/[\\/]/).at(-1).replace(/\.jsonl$/, ""), antigravityIdOf = (file) => String(file).split(/[\\/]/).at(-4), codexLines, codexRolloutFiles = function* () {}, codexCwd = () => null, ANTIGRAVITY_TRANSCRIPT = /(?!)/, antigravityLines, antigravityRoots = () => [], antigravityTranscripts = function* () {}, antigravityContext = () => ({}), rememberAntigravity = () => null } = (await import("./transcript-readers.mjs").catch(() => null)) ?? {};
|
|
213
213
|
const { DATABASE_SESSION = /(?!)/, databaseLines = () => null, databaseSessions = function* () {} } = (await import("./session-databases.mjs").catch(() => null)) ?? {};
|
|
214
214
|
const DATABASE_CLIENTS = ["hermes", "goose", "opencode", "openclaw", "cursor", "copilot"]; // the registry's keys, the name each line carries
|
|
215
|
+
const LIVE_SOURCE = new RegExp(`^(codex|antigravity|${DATABASE_CLIENTS.join("|")}):`); // a sweep entry's id → the client it names (every database client, 0.6.16)
|
|
215
216
|
const READER_FILES = ["transcript-readers.mjs", "session-databases.mjs"];
|
|
216
217
|
/** The readers go where the hook and the counter run: copied from beside this file, else (or for a newer counter) from the deployment. */
|
|
217
218
|
async function installReaders(origin, fromNetwork = false) {
|
|
@@ -236,7 +237,7 @@ function* codexRollouts() {
|
|
|
236
237
|
for (const file of root ? codexRolloutFiles(root) : []) {
|
|
237
238
|
const cwd = codexCwd(file);
|
|
238
239
|
if (cwd && excluded(cwd)) continue;
|
|
239
|
-
yield { id: `codex:${file
|
|
240
|
+
yield { id: `codex:${codexIdOf(file)}`, file };
|
|
240
241
|
}
|
|
241
242
|
yield* antigravityConversations();
|
|
242
243
|
yield* databaseConversations();
|
|
@@ -261,7 +262,7 @@ const parsedLines = (file) => {
|
|
|
261
262
|
if (DATABASE_SESSION.test(file)) return databaseLines(file);
|
|
262
263
|
let lines; try { lines = readFileSync(file, "utf8").split("\n"); } catch { return null; }
|
|
263
264
|
if (CODEX_ROLLOUT.test(file)) return [...codexLines(lines)];
|
|
264
|
-
if (ANTIGRAVITY_TRANSCRIPT.test(file)) { const id = file
|
|
265
|
+
if (ANTIGRAVITY_TRANSCRIPT.test(file)) { const id = antigravityIdOf(file); return [...antigravityLines(lines, { id, ...antigravityContext(id) })]; }
|
|
265
266
|
const out = [];
|
|
266
267
|
for (const raw of lines) { if (!raw.trim()) continue; try { out.push(JSON.parse(raw)); } catch { /* a torn line */ } }
|
|
267
268
|
return out;
|
|
@@ -365,7 +366,7 @@ const messageOf = (line, at) => {
|
|
|
365
366
|
toolUse: line.type === "assistant" && blocks.some((block) => block && block.type === "tool_use"),
|
|
366
367
|
// A tool that waits for the PERSON (a question, a plan to approve): the gap to its result is the person's, not the tool's. The name is read, never the input.
|
|
367
368
|
waitsForPerson: line.type === "assistant" && blocks.some((block) => block && block.type === "tool_use" && WAITS_FOR_PERSON.has(String(block.name))),
|
|
368
|
-
agentic: line.agentic === true,
|
|
369
|
+
agentic: line.agentic === true, cumulative: line.cumulative === true, // the session's running totals (Hermes, Goose without a ledger)
|
|
369
370
|
};
|
|
370
371
|
};
|
|
371
372
|
|
|
@@ -463,7 +464,7 @@ function measure(cwd, messages) {
|
|
|
463
464
|
}
|
|
464
465
|
const model = [...models.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
|
|
465
466
|
const tokens = tokensOf(messages);
|
|
466
|
-
return { cwd, seconds: measured, layers: measured !== null ? timeLayers(messages) : null, ...tokens, exchanges, model, from: messages[0].at, to: messages[messages.length - 1].at };
|
|
467
|
+
return { cwd, seconds: measured, layers: measured !== null ? timeLayers(messages) : null, ...tokens, cumulative: messages.some((message) => message.cumulative), exchanges, model, from: messages[0].at, to: messages[messages.length - 1].at };
|
|
467
468
|
}
|
|
468
469
|
|
|
469
470
|
/**
|
|
@@ -523,10 +524,15 @@ function tokensOf(messages) {
|
|
|
523
524
|
if (!shapeKnown) return { tokensIn: 0, tokensOut: 0, cacheRead: 0, cacheWrite: 0, shapeKnown: false };
|
|
524
525
|
return { tokensIn, tokensOut, cacheRead, cacheWrite, shapeKnown: counted > 0 };
|
|
525
526
|
}
|
|
526
|
-
|
|
527
|
-
//
|
|
528
|
-
|
|
529
|
-
const
|
|
527
|
+
// A SESSION'S TOTALS ARE SENT ONCE (0.6.16, audit H2). Hermes, and Goose without its ledger, keep tokens per SESSION on its last answer, so a resumed
|
|
528
|
+
// session sent its whole total again. Such a stretch sends what the total grew by since the one reported (kept beside the watermark), never a negative.
|
|
529
|
+
const TOTALS = ["tokensIn", "tokensOut", "cacheRead", "cacheWrite"];
|
|
530
|
+
const grownBy = (stretch, reported) => { if (!stretch.cumulative || !stretch.shapeKnown) return reported; const total = Object.fromEntries(TOTALS.map((key) => [key, Math.max(stretch[key], reported?.[key] ?? 0)]));
|
|
531
|
+
for (const key of TOTALS) stretch[key] = Math.max(0, stretch[key] - (reported?.[key] ?? 0)); return total; }; // the total to keep; a fresh session sends its total, as before
|
|
532
|
+
|
|
533
|
+
// WHICH STRETCH, HOW STEERED (0.6.10): a hash of the session id (the file name; Antigravity's, always transcript.jsonl, its conversation's folder) and the day,
|
|
534
|
+
// the same on any computer, so a restored backup is never counted twice; `remote` only when the session runs over SSH, otherwise nothing is said.
|
|
535
|
+
const stretchRef = (file, day) => createHash("sha256").update(`worktrust-stretch|${ANTIGRAVITY_TRANSCRIPT.test(file) ? antigravityIdOf(file) : String(file).split(/[\\/]/).pop().replace(/\.jsonl?$/, "")}|${day}`).digest("base64url");
|
|
530
536
|
const STEERED_HERE = process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY ? "remote" : null;
|
|
531
537
|
|
|
532
538
|
/** The one payload shape this script can build. Every key is a number, a date or an enum value. */
|
|
@@ -668,7 +674,8 @@ function historyEntries(floor = null, all = false) {
|
|
|
668
674
|
entries.push(entry);
|
|
669
675
|
}
|
|
670
676
|
}
|
|
671
|
-
|
|
677
|
+
const seen = new Set(); // ONE SESSION FILE IN TWO PROJECT FOLDERS (a copied folder) IS ONE STRETCH (0.6.16, audit M6), as live and preserve count it
|
|
678
|
+
return entries.filter((entry) => !seen.has(entry.stretch_ref) && seen.add(entry.stretch_ref)).sort((a, b) => a.at.localeCompare(b.at));
|
|
672
679
|
}
|
|
673
680
|
|
|
674
681
|
/** Every transcript this machine holds, excluded projects skipped before a file is opened. */
|
|
@@ -901,51 +908,44 @@ if (floorOf(state, "__from") === null) {
|
|
|
901
908
|
if (!DRY) writeState(state);
|
|
902
909
|
}
|
|
903
910
|
const floor = floorOf(state, "__from");
|
|
904
|
-
let sent = 0;
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
const late = [];
|
|
911
|
+
let sent = 0, filed = 0;
|
|
912
|
+
const late = []; // measured days the live door will no longer take: they are not dropped, they are filed
|
|
913
|
+
const heldBack = new Map(); // per transcript with a late day: its state before that day, put back unless history took every late day
|
|
908
914
|
for (const entry of work) {
|
|
909
915
|
const mark = state[entry.id]?.through ? Date.parse(state[entry.id].through) : null;
|
|
910
916
|
// Never before the coupling (THE FLOORS): a transcript with no watermark starts at `__from`, not at its first line.
|
|
911
917
|
const since = mark !== null && floor !== null ? Math.max(mark, floor) : mark ?? floor;
|
|
912
|
-
// Nothing
|
|
913
|
-
// keeps a sweep over every transcript on the machine to one `stat` each.
|
|
918
|
+
// Nothing written since we last read this one: it is not opened. This keeps a sweep over every transcript to one `stat` each.
|
|
914
919
|
if (since !== null && entry.writtenAt && entry.writtenAt <= since) continue;
|
|
915
920
|
const stretches = stretchesOf(entry.file, since);
|
|
916
921
|
// THE WATERMARK ADVANCES OVER WHAT WAS ACCEPTED, AND STOPS AT THE FIRST REFUSAL. A door that is
|
|
917
922
|
// down must never cost the person a day, so the loop breaks rather than skipping ahead.
|
|
918
|
-
let through = null;
|
|
923
|
+
let through = null, before, reported = state[entry.id]?.reported;
|
|
919
924
|
for (const stretch of stretches) {
|
|
920
925
|
// The day still being worked in is not finished. A fortnight-long session therefore reports
|
|
921
926
|
// every day but today, on whichever run comes first — it no longer waits for its own end.
|
|
922
927
|
if (Date.now() - stretch.to < IDLE_BEFORE_SWEEP) break;
|
|
928
|
+
const total = grownBy(stretch, reported);
|
|
923
929
|
const payload = payloadFor(stretch);
|
|
924
|
-
const source =
|
|
930
|
+
const source = LIVE_SOURCE.exec(String(entry.id))?.[1];
|
|
925
931
|
if (source) entrySource.set(payload, source);
|
|
926
|
-
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; }
|
|
927
|
-
// MEASURED TIME IS NEVER DROPPED. The live door
|
|
928
|
-
//
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
if (Date.now() - stretch.to > WINDOW_HOURS * 3600_000) { late.push(payload); through = stretch.to; continue; }
|
|
932
|
-
try {
|
|
933
|
-
await send(doorFor(entry), payload);
|
|
934
|
-
through = stretch.to;
|
|
935
|
-
sent += 1;
|
|
936
|
-
} catch (error) {
|
|
932
|
+
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; reported = total; continue; }
|
|
933
|
+
// MEASURED TIME IS NEVER DROPPED. The live door refuses anything older than two days, which is right, and used to mean a machine closed
|
|
934
|
+
// for a fortnight lost the fortnight. The same numbers go through the history door instead, standing as history stands. Late is not untrue.
|
|
935
|
+
if (Date.now() - stretch.to > WINDOW_HOURS * 3600_000) { before ??= { mark: through === null ? state[entry.id] : { ...state[entry.id], through: new Date(through).toISOString() } }; late.push(payload); through = stretch.to; reported = total; continue; }
|
|
936
|
+
try { await send(doorFor(entry), payload); through = stretch.to; reported = total; sent += 1; } catch (error) {
|
|
937
937
|
if (process.env.WORKTRUST_HOOK_DEBUG) console.error(`worktrust: ${error.message}`);
|
|
938
938
|
break;
|
|
939
939
|
}
|
|
940
940
|
}
|
|
941
|
-
if (
|
|
941
|
+
if (before && !heldBack.has(entry.id)) heldBack.set(entry.id, before.mark);
|
|
942
|
+
if (through !== null) state[entry.id] = { through: new Date(through).toISOString(), ...(reported ? { reported } : {}) };
|
|
942
943
|
}
|
|
943
|
-
// The history door, in
|
|
944
|
-
// FILLS a line that was missing something rather than adding a twin, so a repeated run is safe.
|
|
944
|
+
// The history door, in batches of two hundred as `--history` sends them. A resend FILLS a line that was missing something, never a twin.
|
|
945
945
|
if (!DRY && late.length > 0) {
|
|
946
|
-
try { filed = await sendHistory(door, late); } catch (error) {
|
|
947
|
-
|
|
948
|
-
}
|
|
946
|
+
try { filed = await sendHistory(door, late); } catch (error) { if (process.env.WORKTRUST_HOOK_DEBUG) console.error(`worktrust: ${error.message}`); }
|
|
947
|
+
// A LATE DAY THE HISTORY DOOR REFUSED IS NOT PASSED: its transcript's mark goes back to before it, so the next run sends it again.
|
|
948
|
+
if (filed < late.length) for (const [id, mark] of heldBack) { if (mark) state[id] = mark; else delete state[id]; }
|
|
949
949
|
}
|
|
950
950
|
if (!DRY && (sent > 0 || filed > 0)) writeState(state);
|
|
951
951
|
|
package/package.json
CHANGED
package/preserve-summary.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* --summary: what a person or a team reads from the archive, counted locally after the chain checks out. Per month the
|
|
3
3
|
* hours, the tokens per model and the share of input served from the cache (where the tokens go, and so where they
|
|
4
|
-
* can be saved); and the PARALLEL WORK: per project and day the sessions'
|
|
5
|
-
* covered together, and the most open at once. A stretch without a project or a measured token stays out of that
|
|
4
|
+
* can be saved); and the PARALLEL WORK: per project and day the sessions' open spans added up against the wall-clock
|
|
5
|
+
* time they covered together (spans on both sides, so the sum is never below the clock), and the most open at once. A stretch without a project or a measured token stays out of that
|
|
6
6
|
* figure; nothing is estimated.
|
|
7
7
|
*
|
|
8
8
|
* Pure: the archive's verified lines in, the lines to print out. No file, no clock, no network.
|
|
@@ -31,7 +31,7 @@ export function summaryLines(lines) {
|
|
|
31
31
|
const edges = spans.flatMap(([from, to]) => [[from, 1], [to, -1]]).sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
32
32
|
let open = 0, top = 0, covered = 0, since = 0;
|
|
33
33
|
for (const [at, step] of edges) { if (open > 0) covered += at - since; open += step; since = at; top = Math.max(top, open); }
|
|
34
|
-
if (top > 1) { parallelDays += 1; peak = Math.max(peak, top); summed += spans.reduce((sum,
|
|
34
|
+
if (top > 1) { parallelDays += 1; peak = Math.max(peak, top); summed += spans.reduce((sum, [from, to]) => sum + (to - from), 0) / 1000; wall += covered / 1000; }
|
|
35
35
|
}
|
|
36
36
|
if (parallelDays > 0) out.push(` parallel sessions in one project on ${parallelDays} day${parallelDays === 1 ? "" : "s"}: up to ${peak} at once, ${hours(summed)} h of session time in ${hours(wall)} h of wall-clock time`);
|
|
37
37
|
}
|
package/preserve.mjs
CHANGED
|
@@ -274,12 +274,12 @@ function archive(dir) {
|
|
|
274
274
|
// A WHOLE ANSWER OR NOTHING: the hook ends with its own count; one line missing or cut is a read to refuse, never part of an archive.
|
|
275
275
|
const rows = hook.stdout.split("\n").filter(Boolean), end = (() => { try { return JSON.parse(rows.at(-1) ?? "").archive_end; } catch { return undefined; } })();
|
|
276
276
|
if (!Number.isInteger(end) || end !== rows.length - 1) { say(` The session hook's answer came back incomplete (${rows.length - 1} of ${end ?? "an unknown number of"} lines), so nothing was added. Run it again.`); process.exit(1); }
|
|
277
|
-
const known = new Set(existing.lines.map((line) => line.stretch_ref)), fresh = [];
|
|
277
|
+
const known = new Set(existing.lines.map((line) => line.stretch_ref)), fresh = [], measured = new Set();
|
|
278
278
|
let waiting = 0;
|
|
279
279
|
for (const text of rows.slice(0, -1)) {
|
|
280
280
|
let entry; try { entry = JSON.parse(text); } catch { continue; }
|
|
281
281
|
const line = archiveLine(entry);
|
|
282
|
-
if (!line || known.has(line.stretch_ref)) continue;
|
|
282
|
+
if (line && line.day < today()) measured.add(line.stretch_ref); if (!line || known.has(line.stretch_ref)) continue; // a file present twice is one stretch
|
|
283
283
|
if (line.day >= today()) { waiting += 1; continue; }
|
|
284
284
|
known.add(line.stretch_ref);
|
|
285
285
|
fresh.push(line);
|
|
@@ -305,7 +305,7 @@ function archive(dir) {
|
|
|
305
305
|
}
|
|
306
306
|
if (fresh.length > 0 || !existing.manifest) writeFileSync(join(dir, "manifest.json"), `${JSON.stringify({ format: "worktrust-ai-evidence/1", collector_version: COLLECTOR, created: existing.manifest?.created ?? new Date().toISOString(), updated: new Date().toISOString(), clients: [...new Set(all.map((line) => line.client))].sort(), ...IDS, count: all.length, head: prev }, null, 1)}\n`);
|
|
307
307
|
say();
|
|
308
|
-
say(` ✓ ${fresh.length} new day-stretch${fresh.length === 1 ? "" : "es"} archived in ${dir}: it holds ${all.length}, the session hook measures ${
|
|
308
|
+
say(` ✓ ${fresh.length} new day-stretch${fresh.length === 1 ? "" : "es"} archived in ${dir}: it holds ${all.length}, the session hook measures ${measured.size} finished ones here now${key ? ", each day's root signed with this computer's device key" : ", unsigned (no device key on this computer)"}.`);
|
|
309
309
|
if (waiting > 0) say(` ${waiting} of today wait for tomorrow's run: a day is archived once it is over.`);
|
|
310
310
|
say(" Metadata only: clocks, counts, model names, a hash of each project's name. Nothing was sent.");
|
|
311
311
|
}
|
package/session-databases.mjs
CHANGED
|
@@ -33,6 +33,7 @@ export const hermesSteered = (source) => (HERMES_DESK.has(source) ? "desk" : HER
|
|
|
33
33
|
let sqlite;
|
|
34
34
|
/** A database session's pseudo-file, `<client>:<database>#<session id>`, back into its parts. */
|
|
35
35
|
const splitSession = (pseudo) => { const at = pseudo.lastIndexOf("#"); return { file: pseudo.slice(pseudo.indexOf(":") + 1, at), id: pseudo.slice(at + 1) }; };
|
|
36
|
+
const isoOf = (value) => { const at = new Date(value); return Number.isFinite(at.getTime()) ? at.toISOString() : null; }; // null: that row is skipped, never the session (0.6.16, M2)
|
|
36
37
|
const openDatabase = (file) => {
|
|
37
38
|
if (sqlite === undefined) {
|
|
38
39
|
// node:sqlite says it is experimental on some Node versions; that line is not the person's business inside a hook.
|
|
@@ -85,10 +86,10 @@ export function hermesLines(pseudo) {
|
|
|
85
86
|
const out = [{ type: "meta", ...(typeof session.cwd === "string" ? { cwd: session.cwd } : {}), steered: hermesSteered(session.source) }];
|
|
86
87
|
const lastAssistant = messages.map((m) => m.role).lastIndexOf("assistant");
|
|
87
88
|
messages.forEach((message, i) => {
|
|
88
|
-
const timestamp =
|
|
89
|
+
const timestamp = isoOf(Number(message.timestamp) * 1000); if (!timestamp) return;
|
|
89
90
|
if (message.role === "user") out.push({ type: "user", timestamp, message: { content: [{ type: "text" }] } });
|
|
90
91
|
else if (message.role === "tool") out.push({ type: "user", timestamp, message: { content: [{ type: "tool_result" }] } });
|
|
91
|
-
else if (message.role === "assistant") out.push({ type: "assistant", timestamp, message: { model, ...(i === lastAssistant ? { usage: { input_tokens: Number(session.input_tokens) || 0, output_tokens: Number(session.output_tokens) || 0, cache_read_input_tokens: Number(session.cache_read_tokens) || 0, cache_creation_input_tokens: Number(session.cache_write_tokens) || 0 } } : {}), content: [] } });
|
|
92
|
+
else if (message.role === "assistant") out.push({ type: "assistant", timestamp, ...(i === lastAssistant ? { cumulative: true } : {}), message: { model, ...(i === lastAssistant ? { usage: { input_tokens: Number(session.input_tokens) || 0, output_tokens: Number(session.output_tokens) || 0, cache_read_input_tokens: Number(session.cache_read_tokens) || 0, cache_creation_input_tokens: Number(session.cache_write_tokens) || 0 } } : {}), content: [] } });
|
|
92
93
|
});
|
|
93
94
|
return out;
|
|
94
95
|
} catch { return null; } finally { db.close(); }
|
|
@@ -146,16 +147,15 @@ export function gooseLines(pseudo) {
|
|
|
146
147
|
const messages = db.prepare(`select role, created_timestamp as at,
|
|
147
148
|
case when json_valid(content_json) then (select group_concat(json_extract(value, '$.type')) from json_each(content_json)) end as kinds
|
|
148
149
|
from messages where session_id = ? order by created_timestamp, id`).all(id);
|
|
149
|
-
const ledger = db.prepare("select 1 from sqlite_master where type = 'table' and name = 'usage_ledger'").get()
|
|
150
|
-
? db.prepare("select created_timestamp as at, model, input_tokens as input, output_tokens as output, cache_read_tokens as cache_read, cache_write_tokens as cache_write from usage_ledger where session_id = ? order by created_timestamp, id").all(id)
|
|
150
|
+
const ledger = db.prepare("select 1 from sqlite_master where type = 'table' and name = 'usage_ledger'").get() ? db.prepare("select created_timestamp as at, model, input_tokens as input, output_tokens as output, cache_read_tokens as cache_read, cache_write_tokens as cache_write from usage_ledger where session_id = ? order by created_timestamp, id").all(id)
|
|
151
151
|
: null;
|
|
152
152
|
const fallbackModel = cleanModel(session.model);
|
|
153
153
|
const lines = messages.map((message) => {
|
|
154
154
|
const kinds = new Set(String(message.kinds ?? "").split(","));
|
|
155
|
-
const timestamp =
|
|
155
|
+
const timestamp = isoOf(ms(message.at)); if (!timestamp) return null;
|
|
156
156
|
if (message.role === "user") return { type: "user", timestamp, message: { content: [{ type: kinds.has("toolResponse") ? "tool_result" : kinds.has("text") || kinds.has("image") ? "text" : "tool_result" }] } };
|
|
157
157
|
return { type: "assistant", timestamp, at: ms(message.at), message: { model: fallbackModel, content: [] } };
|
|
158
|
-
});
|
|
158
|
+
}).filter(Boolean);
|
|
159
159
|
const assistants = lines.filter((line) => line.type === "assistant");
|
|
160
160
|
const usageOf = (row) => ({ input_tokens: Number(row.input) || 0, output_tokens: Number(row.output) || 0, cache_read_input_tokens: Number(row.cache_read) || 0, cache_creation_input_tokens: Number(row.cache_write) || 0 });
|
|
161
161
|
const add = (line, usage) => { const was = line.message.usage; line.message.usage = was ? Object.fromEntries(Object.entries(usage).map(([key, n]) => [key, n + (was[key] ?? 0)])) : usage; };
|
|
@@ -165,7 +165,7 @@ export function gooseLines(pseudo) {
|
|
|
165
165
|
if (!line) continue;
|
|
166
166
|
add(line, usageOf(row));
|
|
167
167
|
if (cleanModel(row.model)) line.message.model = cleanModel(row.model);
|
|
168
|
-
} else if (assistants.length) add(assistants.at(-1), usageOf(session));
|
|
168
|
+
} else if (assistants.length) { add(assistants.at(-1), usageOf(session)); assistants.at(-1).cumulative = true; } // the session's totals: sent as what they grew by
|
|
169
169
|
for (const line of assistants) delete line.at;
|
|
170
170
|
return [{ type: "meta", ...(typeof session.cwd === "string" ? { cwd: session.cwd } : {}), steered: gooseSteered(session.type) }, ...lines];
|
|
171
171
|
} catch { return null; } finally { db.close(); }
|
|
@@ -212,11 +212,11 @@ export function opencodeLines(pseudo) {
|
|
|
212
212
|
json_extract(data, '$.tokens.reasoning') as reasoning, json_extract(data, '$.tokens.cache.read') as cache_read, json_extract(data, '$.tokens.cache.write') as cache_write
|
|
213
213
|
from message where session_id = ? order by time_created, id`).all(id);
|
|
214
214
|
const lines = messages.map((message) => {
|
|
215
|
-
const timestamp =
|
|
215
|
+
const timestamp = isoOf(Number(message.at)); if (!timestamp) return null;
|
|
216
216
|
if (message.role === "user") return { type: "user", timestamp, message: { content: [{ type: "text" }] } };
|
|
217
217
|
const counted = message.input !== null || message.output !== null;
|
|
218
218
|
return { type: "assistant", timestamp, message: { model: cleanModel(message.model), ...(counted ? { usage: { input_tokens: Number(message.input) || 0, output_tokens: (Number(message.output) || 0) + (Number(message.reasoning) || 0), cache_read_input_tokens: Number(message.cache_read) || 0, cache_creation_input_tokens: Number(message.cache_write) || 0 } } : {}), content: [] } };
|
|
219
|
-
});
|
|
219
|
+
}).filter(Boolean);
|
|
220
220
|
return [{ type: "meta", ...(typeof session.cwd === "string" ? { cwd: session.cwd } : {}) }, ...lines];
|
|
221
221
|
} catch { return null; } finally { db.close(); }
|
|
222
222
|
}
|
|
@@ -349,7 +349,7 @@ export function cursorLines(pseudo) {
|
|
|
349
349
|
const model = cleanModel(chat?.model);
|
|
350
350
|
const lines = [];
|
|
351
351
|
for (const bubble of bubbles.sort((a, b) => String(a.at).localeCompare(String(b.at)))) {
|
|
352
|
-
const timestamp =
|
|
352
|
+
const timestamp = isoOf(bubble.at); if (!timestamp) continue;
|
|
353
353
|
if (Number(bubble.type) === 1) lines.push({ type: "user", timestamp, message: { content: [{ type: "text" }] } });
|
|
354
354
|
else if (Number(bubble.type) === 2) {
|
|
355
355
|
const usage = Number(bubble.input) || Number(bubble.output) ? { usage: { input_tokens: Number(bubble.input) || 0, output_tokens: Number(bubble.output) || 0 } } : {};
|
package/setup-mcp.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync
|
|
|
19
19
|
import { spawnSync } from "node:child_process";
|
|
20
20
|
import { homedir, platform } from "node:os";
|
|
21
21
|
import { dirname, join } from "node:path";
|
|
22
|
-
import { placeUnder, takeOut } from "./config-edits.mjs";
|
|
22
|
+
import { isWorkTrustDoor, placeUnder, takeOut } from "./config-edits.mjs";
|
|
23
23
|
|
|
24
24
|
const args = process.argv.slice(2);
|
|
25
25
|
const value = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : undefined; };
|
|
@@ -39,7 +39,7 @@ function fromClaude() {
|
|
|
39
39
|
try {
|
|
40
40
|
const config = JSON.parse(readFileSync(join(homedir(), ".claude.json"), "utf8"));
|
|
41
41
|
const found = [];
|
|
42
|
-
const walk = (node) => { if (!node || typeof node !== "object") return; if (typeof node.url === "string" && node.url
|
|
42
|
+
const walk = (node) => { if (!node || typeof node !== "object") return; if (typeof node.url === "string" && isWorkTrustDoor(node.url)) { const auth = node.headers?.Authorization ?? node.headers?.authorization ?? ""; if (auth.startsWith("Bearer ")) found.push({ url: node.url, token: auth.slice(7) }); } for (const child of Object.values(node)) walk(child); };
|
|
43
43
|
walk(config); return found[0] ?? null;
|
|
44
44
|
} catch { return null; }
|
|
45
45
|
}
|
|
@@ -54,7 +54,7 @@ const discovered = fromClaude();
|
|
|
54
54
|
function directClaudeEntries() {
|
|
55
55
|
try {
|
|
56
56
|
const config = JSON.parse(readFileSync(join(homedir(), ".claude.json"), "utf8"));
|
|
57
|
-
const door = (server) => typeof server?.url === "string" &&
|
|
57
|
+
const door = (server) => typeof server?.url === "string" && isWorkTrustDoor(server.url);
|
|
58
58
|
const entries = Object.entries(config.mcpServers ?? {}).filter(([, server]) => door(server)).map(([name]) => ({ name, scope: "user", dir: null }));
|
|
59
59
|
for (const [dir, project] of Object.entries(config.projects ?? {})) for (const [name, server] of Object.entries(project?.mcpServers ?? {})) if (door(server)) entries.push({ name, scope: "local", dir });
|
|
60
60
|
const bridged = Object.values(config.mcpServers ?? {}).some((server) => Array.isArray(server?.args) && server.args.includes(BRIDGE));
|
package/transcript-readers.mjs
CHANGED
|
@@ -38,7 +38,10 @@ import { fileURLToPath } from "node:url";
|
|
|
38
38
|
* The running total in event_msg/token_count is never read: it is a counter, not a record.
|
|
39
39
|
* Text is read here for the rubric and the layer and travels nowhere, as with every transcript.
|
|
40
40
|
*/
|
|
41
|
-
|
|
41
|
+
// Either separator (0.6.16, audit H4): on Windows a path reads C:\Users\…\rollout-….jsonl, and a pattern of `/` alone matched none.
|
|
42
|
+
export const CODEX_ROLLOUT = /(^|[\\/])rollout-\d{4}-\d{2}-\d{2}T[\d-]+-[0-9a-f-]{36}\.jsonl$/;
|
|
43
|
+
/** A rollout's thread id: its file's name without .jsonl, split on either separator. */
|
|
44
|
+
export const codexIdOf = (file) => String(file).split(/[\\/]/).at(-1).replace(/\.jsonl$/, "");
|
|
42
45
|
export const CODEX_HARNESS_TURN = /^\s*<[a-z][a-z_]*[\s>]/i;
|
|
43
46
|
export const codexToolName = (name) => (name === "exec" || name === "shell" || name === "container.exec" || name === "local_shell" ? "Bash" : name === "apply_patch" ? "Edit" : name === "spawn_agent" ? "Agent" : String(name ?? "tool"));
|
|
44
47
|
export const codexToolInput = (name, raw) => {
|
|
@@ -134,7 +137,9 @@ export function codexCwd(file) {
|
|
|
134
137
|
* `thinking` is never read. No usage block is made up: such a stretch is sent without tokens.
|
|
135
138
|
* A step written twice (RUNNING, then DONE) is read once, on its step_index.
|
|
136
139
|
*/
|
|
137
|
-
export const ANTIGRAVITY_TRANSCRIPT = /(
|
|
140
|
+
export const ANTIGRAVITY_TRANSCRIPT = /(^|[\\/])brain[\\/][^\\/]+[\\/]\.system_generated[\\/]logs[\\/]transcript\.jsonl$/; // either separator (0.6.16, H4)
|
|
141
|
+
/** A transcript's conversation id: the folder under brain/, split on either separator. */
|
|
142
|
+
export const antigravityIdOf = (file) => String(file).split(/[\\/]/).at(-4);
|
|
138
143
|
const ANTIGRAVITY_SKIPPED = new Set(["CONVERSATION_HISTORY", "CHECKPOINT", "SYSTEM_MESSAGE", "EPHEMERAL_MESSAGE"]);
|
|
139
144
|
/** A model name as a client names it, or null: the hook's modelName is the only one, and a name is all it may be. */
|
|
140
145
|
export const cleanModel = (name) => (typeof name === "string" && /^[A-Za-z0-9][A-Za-z0-9._:/ -]{0,63}$/.test(name.trim()) ? name.trim() : null);
|
package/worktrust.mjs
CHANGED
|
@@ -69,7 +69,7 @@ const command = args.find((arg, at) => !arg.startsWith("--") && !(at > 0 && VALU
|
|
|
69
69
|
const flag = (name) => { const at = args.indexOf(`--${name}`); return at >= 0 ? args[at + 1] : undefined; };
|
|
70
70
|
const has = (name) => args.includes(`--${name}`);
|
|
71
71
|
/** This CLI's version, said to the door so the app can tell which computer runs an old one (check-cli-package holds it equal to package.json). */
|
|
72
|
-
const CLI_VERSION = "0.6.
|
|
72
|
+
const CLI_VERSION = "0.6.16";
|
|
73
73
|
const ORIGIN = (flag("origin") ?? process.env.WORKTRUST_ORIGIN ?? "https://app.worktrust.io").replace(/\/$/, "");
|
|
74
74
|
const MCP = flag("url") ?? process.env.WORKTRUST_MCP_URL ?? `${ORIGIN}/api/mcp`;
|
|
75
75
|
const HOME_DIR = join(homedir(), ".worktrust");
|
|
@@ -81,6 +81,8 @@ const downloaded = (name) => (IMPORTED.has(name) ? name : `${name}.download.mjs`
|
|
|
81
81
|
const BUNDLED = SCRIPTS.every((name) => existsSync(join(HERE, name)));
|
|
82
82
|
const PLACEHOLDER = `wt_${"0".repeat(43)}`;
|
|
83
83
|
const say = (line = "") => process.stdout.write(`${line}\n`);
|
|
84
|
+
// A reader that closed the pipe (`worktrust status | head`) wants no more: stop quietly, never an EPIPE stack (0.6.16, audit L4).
|
|
85
|
+
process.stdout.on("error", (error) => { if (error?.code === "EPIPE") process.exit(0); throw error; });
|
|
84
86
|
const fail = (line) => { process.stderr.write(`worktrust: ${line}\n`); process.exit(1); };
|
|
85
87
|
const OS = { darwin: "macos", win32: "windows", linux: "linux" }[platform()] ?? "other";
|
|
86
88
|
const OS_NAME = { macos: "macOS", windows: "Windows", linux: "Linux", other: "this system" }[OS];
|
|
@@ -576,7 +578,8 @@ async function connect() {
|
|
|
576
578
|
say(` + ${join(HOME_DIR, "log-session.mjs")} and ${join(HOME_DIR, "count-behaviour.mjs")}${BUNDLED ? " (from this package, not updated from the network)" : ""}`);
|
|
577
579
|
say(` + ${join(homedir(), ".claude", "settings.json")} → SessionStart, SessionEnd and PreCompact run it`);
|
|
578
580
|
say();
|
|
579
|
-
say(" Sent to WorkTrust afterwards: durations, token counts, model names, a layer keyword, counts
|
|
581
|
+
say(" Sent to WorkTrust afterwards: durations, token counts, model names, a layer keyword, counts,");
|
|
582
|
+
say(" and, to match the work to GitHub, the repository's owner/name and the commit hashes.");
|
|
580
583
|
say(" Never sent: prompts, answers, code, file paths, commit messages.");
|
|
581
584
|
say();
|
|
582
585
|
if (has("dry-run")) { say(" Dry run: nothing was written and nothing was sent."); return; }
|
|
@@ -634,8 +637,8 @@ async function offerHistory(paths, token, devicePem, url = MCP) {
|
|
|
634
637
|
let found = null;
|
|
635
638
|
try { found = JSON.parse(counted.out.trim().split("\n").pop()); } catch { /* no line: nothing to offer */ }
|
|
636
639
|
say();
|
|
637
|
-
if (!found?.sessions) { say("
|
|
638
|
-
say(` This computer holds ${found.sessions} earlier
|
|
640
|
+
if (!found?.sessions) { say(" Your AI apps hold no earlier sessions on this computer to send."); return; }
|
|
641
|
+
say(` This computer holds ${found.sessions} earlier sessions of your AI apps · ${found.hours} measured hours · ${found.first} … ${found.last}.`);
|
|
639
642
|
if (has("dry-run")) { say(" Dry run: nothing was sent."); return; }
|
|
640
643
|
if (!has("history") && !(await ask("Send these as history (hours and tokens per day; never text)?", false))) { say(" Not sent. Send them later with: npx worktrust@latest history"); return; }
|
|
641
644
|
const sent = await run(paths["log-session.mjs"], ["--history"], token, true, env);
|