worktrust 0.6.15 → 0.6.17
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 +36 -4
- package/config-edits.mjs +15 -0
- package/log-session.mjs +54 -55
- package/package.json +1 -1
- package/session-databases.mjs +10 -10
- package/setup-mcp.mjs +3 -3
- package/transcript-readers.mjs +7 -2
- package/worktrust.mjs +46 -9
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
|
|
@@ -79,6 +79,8 @@ yourself run a command that sends. See [Keep your history](#keep-your-history).
|
|
|
79
79
|
|
|
80
80
|
```
|
|
81
81
|
npx worktrust@latest history send this computer's earlier sessions later (asks first)
|
|
82
|
+
npx worktrust@latest history --rebuild re-measure them with the current rules; WorkTrust replaces this computer's
|
|
83
|
+
earlier lines day by day, never counting a day twice (shows the plan, asks first)
|
|
82
84
|
npx worktrust codex let Codex wake the session hook (asks first)
|
|
83
85
|
npx worktrust antigravity let Antigravity wake the session hook (asks first)
|
|
84
86
|
npx worktrust@latest status what is coupled here
|
|
@@ -179,6 +181,36 @@ and counts, exactly as the session hook reads it.
|
|
|
179
181
|
[worktrust.io/preserve](https://worktrust.io/preserve) is this package plus a guideline, for teams
|
|
180
182
|
that want the same record without WorkTrust.
|
|
181
183
|
|
|
184
|
+
## Rebuild the history
|
|
185
|
+
|
|
186
|
+
A newer version of this command measures a session differently from an older one: what the seconds were (the model
|
|
187
|
+
answering, tools running, your own turns, pauses), your own turns rather than every message, tokens with the cache,
|
|
188
|
+
and more AI apps (Cursor, Copilot, Hermes, Goose, OpenCode, OpenClaw, Codex, Antigravity). Lines an earlier version
|
|
189
|
+
sent stand as they were measured then, and sending the sessions again would count those days twice: an old line
|
|
190
|
+
does not name its stretch, so WorkTrust cannot tell it from the one that arrives.
|
|
191
|
+
|
|
192
|
+
`npx worktrust@latest history --rebuild` re-measures every session still on this computer with the current rules and
|
|
193
|
+
sends them with one difference: WorkTrust **replaces** this computer's earlier lines, day by day.
|
|
194
|
+
|
|
195
|
+
- **What it does.** It shows the plan first: per AI app, the days and the measured hours it would send, and the first
|
|
196
|
+
and last day. It asks once; Enter is No. `--dry-run` shows the plan and stops. Nothing is sent before a typed `y`.
|
|
197
|
+
For every day and AI app it carries, WorkTrust withdraws this computer's earlier lines (sent by this key or by an
|
|
198
|
+
earlier key of the same computer, live or as history), each with a correction row that says why, and then stores the
|
|
199
|
+
re-measured lines. A line that arrives unchanged is kept as it is. A day is counted once, and the same rebuild sent
|
|
200
|
+
twice changes nothing.
|
|
201
|
+
- **What it never does.** A replaced line is withdrawn and kept, never deleted: it stays in your record and the record shows
|
|
202
|
+
each replacement; deleting your account still removes all of it. Never touched: another computer's lines, what a web
|
|
203
|
+
connector (Claude or ChatGPT on the web) brought in, an AI app the batch does not carry, a day it does not carry, a line
|
|
204
|
+
you made private, a line less than 48 hours old (the session hook's own window), and a line received in the last 12
|
|
205
|
+
hours (the batches of one run never replace each other). It sends what `history` sends and nothing more: durations,
|
|
206
|
+
token counts, model names, a layer keyword, counts; never text.
|
|
207
|
+
- **Only from this computer's own key.** A connector (an AI signed in on the web) cannot ask for a rebuild; WorkTrust
|
|
208
|
+
refuses it at the door.
|
|
209
|
+
- **Offered once by `update`.** When this computer's earlier lines were made by an earlier version (the copy in
|
|
210
|
+
`~/.worktrust` says which), `npx worktrust@latest update` says so in plain words and offers the rebuild; Enter is No,
|
|
211
|
+
and the same version updated again offers nothing. The command cannot read your record, so it is an offer, not a
|
|
212
|
+
finding.
|
|
213
|
+
|
|
182
214
|
## Where the key lives, and why it only works here
|
|
183
215
|
|
|
184
216
|
The key is kept in **one file only you can read**: `~/.worktrust/key.json` (mode 600, in a folder of
|
|
@@ -202,11 +234,11 @@ which would delete every password on the computer, and many people rightly keep
|
|
|
202
234
|
|
|
203
235
|
| | |
|
|
204
236
|
|---|---|
|
|
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
|
|
237
|
+
| **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
238
|
| **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
239
|
| **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
|
|
240
|
+
| **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). |
|
|
241
|
+
| **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
242
|
|
|
211
243
|
## Why you can check it
|
|
212
244
|
|
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
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
* node log-session.mjs --uninstall [--purge] take it out again (--purge also deletes the copies)
|
|
24
24
|
* node log-session.mjs --dry-run show what the current transcripts would send
|
|
25
25
|
* node log-session.mjs --history [--dry-run] send the months already on this machine, as HISTORY
|
|
26
|
-
* node log-session.mjs --history --
|
|
26
|
+
* node log-session.mjs --history --rebuild the same, re-measured: the door REPLACES this computer's earlier lines, day by day
|
|
27
|
+
* node log-session.mjs --history --summary one JSON line: sessions, hours, first and last month, per client (sends nothing)
|
|
27
28
|
* (as a hook) reads the hook's JSON on stdin, logs, exits
|
|
28
29
|
* (from Codex) notify = ["node", "~/.worktrust/log-session.mjs", "--codex-notify"]
|
|
29
30
|
* in ~/.codex/config.toml: Codex calls it at every turn's end and
|
|
@@ -38,8 +39,8 @@
|
|
|
38
39
|
* `worktrust.mjs hook --antigravity-stop`. The readers live in transcript-readers.mjs beside this file.
|
|
39
40
|
*
|
|
40
41
|
* 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
|
|
42
|
+
* dates or vocabulary from the door's own enums, and the repository as owner/name and commit hashes. No prompt, no
|
|
43
|
+
* answer, no file path, no commit message, no branch, no folder name. File paths ARE read locally to decide one layer keyword,
|
|
43
44
|
* the way the connector reads them to decide an activity class, and only the keyword travels.
|
|
44
45
|
*
|
|
45
46
|
* WHAT IS MEASURED, AND WHAT IS THEREFORE NOT SENT. A stretch's seconds are the sum of the gaps
|
|
@@ -61,11 +62,9 @@ import { fileURLToPath } from "node:url";
|
|
|
61
62
|
import { createHash, randomBytes, sign as cryptoSign } from "node:crypto";
|
|
62
63
|
|
|
63
64
|
const args = process.argv.slice(2);
|
|
64
|
-
const flag = (name) => args.includes(`--${name}`);
|
|
65
|
+
const flag = (name) => args.includes(`--${name}`), value = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : undefined; }, DRY = flag("dry-run");
|
|
65
66
|
// Exit once stdout is written: to a pipe it is asynchronous and process.exit drops what is queued (0.6.13 cut --archive-lines).
|
|
66
67
|
const exitFlushed = (code) => new Promise((resolve) => process.stdout.write("", resolve)).then(() => process.exit(code));
|
|
67
|
-
const value = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : undefined; };
|
|
68
|
-
const DRY = flag("dry-run");
|
|
69
68
|
|
|
70
69
|
/**
|
|
71
70
|
* THE OTHER HALF OF A SESSION: HOW IT WAS ASKED.
|
|
@@ -136,8 +135,7 @@ const DAY_SECONDS = 86400;
|
|
|
136
135
|
const TOOL_CAP = 1800;
|
|
137
136
|
/** Tools whose result waits for the person: the gap to it is the person's time. Names only; nothing of the call is read. */
|
|
138
137
|
const WAITS_FOR_PERSON = new Set(["AskUserQuestion", "ExitPlanMode"]);
|
|
139
|
-
const AGENT_RUNS_MAX = 10000;
|
|
140
|
-
const AGENT_SECONDS_MAX = 2592000;
|
|
138
|
+
const AGENT_RUNS_MAX = 10000, AGENT_SECONDS_MAX = 2592000;
|
|
141
139
|
|
|
142
140
|
/**
|
|
143
141
|
* WHEN THE HOOK RUNS. SessionEnd alone was a bet that sessions end, and a session that stays open
|
|
@@ -151,8 +149,7 @@ const AGENT_SECONDS_MAX = 2592000;
|
|
|
151
149
|
const HOOK_EVENTS = ["SessionEnd", "SessionStart", "PreCompact"];
|
|
152
150
|
|
|
153
151
|
const STATE = join(homedir(), ".worktrust", "sessions.json");
|
|
154
|
-
const readState = () => { try { return JSON.parse(readFileSync(STATE, "utf8")); } catch { return {}; } };
|
|
155
|
-
const writeState = (state) => { mkdirSync(dirname(STATE), { recursive: true }); writeFileSync(STATE, JSON.stringify(state, null, 1)); };
|
|
152
|
+
const readState = () => { try { return JSON.parse(readFileSync(STATE, "utf8")); } catch { return {}; } }, writeState = (state) => { mkdirSync(dirname(STATE), { recursive: true }); writeFileSync(STATE, JSON.stringify(state, null, 1)); };
|
|
156
153
|
/**
|
|
157
154
|
* THE FLOORS (owner, 2026-10-04: "history is never sent silently"). `__from` is the moment this
|
|
158
155
|
* computer was coupled: the hook sends nothing measured before it, so a fresh coupling (or one after
|
|
@@ -209,9 +206,10 @@ const excluded = (path) => excludes.some((text) => text && path.includes(text));
|
|
|
209
206
|
* reader), the database clients in session-databases.mjs. The install copies both into ~/.worktrust with this hook.
|
|
210
207
|
* Without them this hook still reads Claude Code; the never-matching patterns keep every other client out.
|
|
211
208
|
*/
|
|
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)) ?? {};
|
|
209
|
+
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
210
|
const { DATABASE_SESSION = /(?!)/, databaseLines = () => null, databaseSessions = function* () {} } = (await import("./session-databases.mjs").catch(() => null)) ?? {};
|
|
214
211
|
const DATABASE_CLIENTS = ["hermes", "goose", "opencode", "openclaw", "cursor", "copilot"]; // the registry's keys, the name each line carries
|
|
212
|
+
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
213
|
const READER_FILES = ["transcript-readers.mjs", "session-databases.mjs"];
|
|
216
214
|
/** 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
215
|
async function installReaders(origin, fromNetwork = false) {
|
|
@@ -236,7 +234,7 @@ function* codexRollouts() {
|
|
|
236
234
|
for (const file of root ? codexRolloutFiles(root) : []) {
|
|
237
235
|
const cwd = codexCwd(file);
|
|
238
236
|
if (cwd && excluded(cwd)) continue;
|
|
239
|
-
yield { id: `codex:${file
|
|
237
|
+
yield { id: `codex:${codexIdOf(file)}`, file };
|
|
240
238
|
}
|
|
241
239
|
yield* antigravityConversations();
|
|
242
240
|
yield* databaseConversations();
|
|
@@ -261,7 +259,7 @@ const parsedLines = (file) => {
|
|
|
261
259
|
if (DATABASE_SESSION.test(file)) return databaseLines(file);
|
|
262
260
|
let lines; try { lines = readFileSync(file, "utf8").split("\n"); } catch { return null; }
|
|
263
261
|
if (CODEX_ROLLOUT.test(file)) return [...codexLines(lines)];
|
|
264
|
-
if (ANTIGRAVITY_TRANSCRIPT.test(file)) { const id = file
|
|
262
|
+
if (ANTIGRAVITY_TRANSCRIPT.test(file)) { const id = antigravityIdOf(file); return [...antigravityLines(lines, { id, ...antigravityContext(id) })]; }
|
|
265
263
|
const out = [];
|
|
266
264
|
for (const raw of lines) { if (!raw.trim()) continue; try { out.push(JSON.parse(raw)); } catch { /* a torn line */ } }
|
|
267
265
|
return out;
|
|
@@ -365,7 +363,7 @@ const messageOf = (line, at) => {
|
|
|
365
363
|
toolUse: line.type === "assistant" && blocks.some((block) => block && block.type === "tool_use"),
|
|
366
364
|
// 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
365
|
waitsForPerson: line.type === "assistant" && blocks.some((block) => block && block.type === "tool_use" && WAITS_FOR_PERSON.has(String(block.name))),
|
|
368
|
-
agentic: line.agentic === true,
|
|
366
|
+
agentic: line.agentic === true, cumulative: line.cumulative === true, // the session's running totals (Hermes, Goose without a ledger)
|
|
369
367
|
};
|
|
370
368
|
};
|
|
371
369
|
|
|
@@ -463,7 +461,7 @@ function measure(cwd, messages) {
|
|
|
463
461
|
}
|
|
464
462
|
const model = [...models.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
|
|
465
463
|
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 };
|
|
464
|
+
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
465
|
}
|
|
468
466
|
|
|
469
467
|
/**
|
|
@@ -523,10 +521,15 @@ function tokensOf(messages) {
|
|
|
523
521
|
if (!shapeKnown) return { tokensIn: 0, tokensOut: 0, cacheRead: 0, cacheWrite: 0, shapeKnown: false };
|
|
524
522
|
return { tokensIn, tokensOut, cacheRead, cacheWrite, shapeKnown: counted > 0 };
|
|
525
523
|
}
|
|
526
|
-
|
|
527
|
-
//
|
|
528
|
-
|
|
529
|
-
const
|
|
524
|
+
// 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
|
|
525
|
+
// 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.
|
|
526
|
+
const TOTALS = ["tokensIn", "tokensOut", "cacheRead", "cacheWrite"];
|
|
527
|
+
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)]));
|
|
528
|
+
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
|
|
529
|
+
|
|
530
|
+
// 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,
|
|
531
|
+
// 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.
|
|
532
|
+
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
533
|
const STEERED_HERE = process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY ? "remote" : null;
|
|
531
534
|
|
|
532
535
|
/** The one payload shape this script can build. Every key is a number, a date or an enum value. */
|
|
@@ -574,7 +577,7 @@ function payloadFor(stretch) {
|
|
|
574
577
|
* exactly what landed. `announce` because one caller is a person watching a terminal and the other
|
|
575
578
|
* is a hook holding a session's exit open.
|
|
576
579
|
*/
|
|
577
|
-
async function sendHistory(door, entries, announce = false) {
|
|
580
|
+
async function sendHistory(door, entries, announce = false, rebuild = false) {
|
|
578
581
|
let accepted = 0;
|
|
579
582
|
// A Codex entry goes through Codex's own coupling where one exists (see codexDoor), the rest
|
|
580
583
|
// through the door given: the record files a line under the coupling that sent it. Antigravity's
|
|
@@ -584,9 +587,10 @@ async function sendHistory(door, entries, announce = false) {
|
|
|
584
587
|
if (group.length === 0 || !target) continue;
|
|
585
588
|
for (let i = 0; i < group.length; i += 200) {
|
|
586
589
|
const batch = group.slice(i, i + 200);
|
|
587
|
-
// History carries no shas: an imported line forms no session, and the join is the live route's.
|
|
588
|
-
|
|
589
|
-
const
|
|
590
|
+
// History carries no shas: an imported line forms no session, and the join is the live route's. REBUILD (owner, 2026-10-07): `rebuild: true`
|
|
591
|
+
// tells the door to replace this computer's earlier lines on the days and for the client the batch carries, and every group names its client.
|
|
592
|
+
const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "import_history", arguments: { ...(rebuild ? { rebuild: true } : {}), entries: batch.map((entry) => { const { commits, ...rest } = entry; void commits; return rest; }) } } });
|
|
593
|
+
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, rebuild), ...proofFor(target.url, body) }, body });
|
|
590
594
|
const text = await response.text();
|
|
591
595
|
if (announce) console.log(`batch ${Math.floor(i / 200) + 1}: ${response.status} ${text.slice(0, 200).replace(/\\n/g, " ")}`);
|
|
592
596
|
if (!response.ok) break;
|
|
@@ -610,7 +614,7 @@ const proofFor = (url, body) => {
|
|
|
610
614
|
* on this computer, and the door names a coupling's clients by the user-agent it hears (mcp_identify's
|
|
611
615
|
* agents_seen). An Antigravity line names Antigravity: the product name, nothing of the work.
|
|
612
616
|
*/
|
|
613
|
-
const clientOf = (source) => (source === "antigravity" || DATABASE_CLIENTS.includes(source) ? { "user-agent": `${source}/hook (worktrust-hook)` } : {});
|
|
617
|
+
const clientOf = (source, always = false) => (always || source === "antigravity" || DATABASE_CLIENTS.includes(source) ? { "user-agent": `${source ?? "claude"}/hook (worktrust-hook)` } : {});
|
|
614
618
|
|
|
615
619
|
async function send(door, payload) {
|
|
616
620
|
const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "log_work", arguments: payload } });
|
|
@@ -668,7 +672,8 @@ function historyEntries(floor = null, all = false) {
|
|
|
668
672
|
entries.push(entry);
|
|
669
673
|
}
|
|
670
674
|
}
|
|
671
|
-
|
|
675
|
+
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
|
|
676
|
+
return entries.filter((entry) => !seen.has(entry.stretch_ref) && seen.add(entry.stretch_ref)).sort((a, b) => a.at.localeCompare(b.at));
|
|
672
677
|
}
|
|
673
678
|
|
|
674
679
|
/** Every transcript this machine holds, excluded projects skipped before a file is opened. */
|
|
@@ -863,17 +868,18 @@ if (flag("history")) {
|
|
|
863
868
|
const entries = historyEntries(historyFloor);
|
|
864
869
|
const hours = entries.reduce((sum, entry) => sum + (entry.seconds ?? 0), 0) / 3600;
|
|
865
870
|
const months = [...new Set(entries.map((entry) => entry.at.slice(0, 7)))].sort();
|
|
866
|
-
// ONE LINE FOR `npx worktrust` TO ASK WITH (2026-10-03): sessions, hours, first and last month; nothing sent.
|
|
867
|
-
|
|
868
|
-
console.log(
|
|
871
|
+
// ONE LINE FOR `npx worktrust` TO ASK WITH (2026-10-03): sessions, hours, first and last month; per client its days and hours (the rebuild's plan, 2026-10-07); nothing sent.
|
|
872
|
+
const clients = [...entries.reduce((map, entry) => { const client = entrySource.get(entry) ?? "claude", row = map.get(client) ?? { client, days: new Set(), seconds: 0 }; row.days.add(entry.at.slice(0, 10)); row.seconds += entry.seconds ?? 0; return map.set(client, row); }, new Map()).values()].map((row) => ({ client: row.client, days: row.days.size, hours: Math.round(row.seconds / 360) / 10, first: [...row.days].sort()[0], last: [...row.days].sort().at(-1) }));
|
|
873
|
+
if (flag("summary")) { console.log(JSON.stringify({ sessions: new Set(entries.map((entry) => entryFile.get(entry).file)).size, hours: Math.round(hours * 10) / 10, first: months[0] ?? null, last: months.at(-1) ?? null, clients })); await exitFlushed(0); }
|
|
874
|
+
console.log(`${entries.length} day-stretches · ${hours.toFixed(1)} measured hours · ${months.join(", ")}${flag("rebuild") ? " · rebuild: this computer's earlier lines for these days and clients are replaced at the door" : ""}`);
|
|
869
875
|
if (DRY) {
|
|
870
876
|
// One example in full: the shape is the argument for trusting it, and a summary hides it.
|
|
871
877
|
if (entries[0]) console.log(`\nthe oldest of them, in full:\n${JSON.stringify(entries[0], null, 1)}`);
|
|
872
|
-
console.log(
|
|
878
|
+
console.log(`\ndry run — nothing sent. Add --history${flag("rebuild") ? " --rebuild" : ""} without --dry-run to ${flag("rebuild") ? "rebuild" : "import"} them.`);
|
|
873
879
|
await exitFlushed(0);
|
|
874
880
|
}
|
|
875
881
|
// A batch the door refused is a failure the caller must see, not a quiet zero.
|
|
876
|
-
await exitFlushed((await sendHistory(door, entries, true)) === entries.length ? 0 : 1);
|
|
882
|
+
await exitFlushed((await sendHistory(door, entries, true, flag("rebuild"))) === entries.length ? 0 : 1);
|
|
877
883
|
}
|
|
878
884
|
|
|
879
885
|
const input = hookInput();
|
|
@@ -901,51 +907,44 @@ if (floorOf(state, "__from") === null) {
|
|
|
901
907
|
if (!DRY) writeState(state);
|
|
902
908
|
}
|
|
903
909
|
const floor = floorOf(state, "__from");
|
|
904
|
-
let sent = 0;
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
const late = [];
|
|
910
|
+
let sent = 0, filed = 0;
|
|
911
|
+
const late = []; // measured days the live door will no longer take: they are not dropped, they are filed
|
|
912
|
+
const heldBack = new Map(); // per transcript with a late day: its state before that day, put back unless history took every late day
|
|
908
913
|
for (const entry of work) {
|
|
909
914
|
const mark = state[entry.id]?.through ? Date.parse(state[entry.id].through) : null;
|
|
910
915
|
// Never before the coupling (THE FLOORS): a transcript with no watermark starts at `__from`, not at its first line.
|
|
911
916
|
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.
|
|
917
|
+
// Nothing written since we last read this one: it is not opened. This keeps a sweep over every transcript to one `stat` each.
|
|
914
918
|
if (since !== null && entry.writtenAt && entry.writtenAt <= since) continue;
|
|
915
919
|
const stretches = stretchesOf(entry.file, since);
|
|
916
920
|
// THE WATERMARK ADVANCES OVER WHAT WAS ACCEPTED, AND STOPS AT THE FIRST REFUSAL. A door that is
|
|
917
921
|
// down must never cost the person a day, so the loop breaks rather than skipping ahead.
|
|
918
|
-
let through = null;
|
|
922
|
+
let through = null, before, reported = state[entry.id]?.reported;
|
|
919
923
|
for (const stretch of stretches) {
|
|
920
924
|
// The day still being worked in is not finished. A fortnight-long session therefore reports
|
|
921
925
|
// every day but today, on whichever run comes first — it no longer waits for its own end.
|
|
922
926
|
if (Date.now() - stretch.to < IDLE_BEFORE_SWEEP) break;
|
|
927
|
+
const total = grownBy(stretch, reported);
|
|
923
928
|
const payload = payloadFor(stretch);
|
|
924
|
-
const source =
|
|
929
|
+
const source = LIVE_SOURCE.exec(String(entry.id))?.[1];
|
|
925
930
|
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) {
|
|
931
|
+
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; }
|
|
932
|
+
// MEASURED TIME IS NEVER DROPPED. The live door refuses anything older than two days, which is right, and used to mean a machine closed
|
|
933
|
+
// for a fortnight lost the fortnight. The same numbers go through the history door instead, standing as history stands. Late is not untrue.
|
|
934
|
+
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; }
|
|
935
|
+
try { await send(doorFor(entry), payload); through = stretch.to; reported = total; sent += 1; } catch (error) {
|
|
937
936
|
if (process.env.WORKTRUST_HOOK_DEBUG) console.error(`worktrust: ${error.message}`);
|
|
938
937
|
break;
|
|
939
938
|
}
|
|
940
939
|
}
|
|
941
|
-
if (
|
|
940
|
+
if (before && !heldBack.has(entry.id)) heldBack.set(entry.id, before.mark);
|
|
941
|
+
if (through !== null) state[entry.id] = { through: new Date(through).toISOString(), ...(reported ? { reported } : {}) };
|
|
942
942
|
}
|
|
943
|
-
// The history door, in
|
|
944
|
-
// FILLS a line that was missing something rather than adding a twin, so a repeated run is safe.
|
|
943
|
+
// 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
944
|
if (!DRY && late.length > 0) {
|
|
946
|
-
try { filed = await sendHistory(door, late); } catch (error) {
|
|
947
|
-
|
|
948
|
-
}
|
|
945
|
+
try { filed = await sendHistory(door, late); } catch (error) { if (process.env.WORKTRUST_HOOK_DEBUG) console.error(`worktrust: ${error.message}`); }
|
|
946
|
+
// 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.
|
|
947
|
+
if (filed < late.length) for (const [id, mark] of heldBack) { if (mark) state[id] = mark; else delete state[id]; }
|
|
949
948
|
}
|
|
950
949
|
if (!DRY && (sent > 0 || filed > 0)) writeState(state);
|
|
951
950
|
|
package/package.json
CHANGED
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
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* npx worktrust@latest connect couple again, also on a computer already coupled
|
|
11
11
|
* npx worktrust@latest update this computer onto the newest CLI, same key, no new pairing
|
|
12
12
|
* npx worktrust@latest history send this computer's earlier sessions as history (asks first)
|
|
13
|
+
* npx worktrust@latest history --rebuild re-measure them with the current rules; WorkTrust replaces this computer's
|
|
14
|
+
* earlier lines day by day, never counting a day twice (shows the plan, asks first)
|
|
13
15
|
* npx worktrust@latest disconnect show what comes out, ask, take it out again
|
|
14
16
|
* npx worktrust@latest status what is coupled here
|
|
15
17
|
* npx worktrust@latest preserve how long each AI app here keeps its sessions (--apply keeps them,
|
|
@@ -69,7 +71,7 @@ const command = args.find((arg, at) => !arg.startsWith("--") && !(at > 0 && VALU
|
|
|
69
71
|
const flag = (name) => { const at = args.indexOf(`--${name}`); return at >= 0 ? args[at + 1] : undefined; };
|
|
70
72
|
const has = (name) => args.includes(`--${name}`);
|
|
71
73
|
/** 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.
|
|
74
|
+
const CLI_VERSION = "0.6.17";
|
|
73
75
|
const ORIGIN = (flag("origin") ?? process.env.WORKTRUST_ORIGIN ?? "https://app.worktrust.io").replace(/\/$/, "");
|
|
74
76
|
const MCP = flag("url") ?? process.env.WORKTRUST_MCP_URL ?? `${ORIGIN}/api/mcp`;
|
|
75
77
|
const HOME_DIR = join(homedir(), ".worktrust");
|
|
@@ -81,6 +83,8 @@ const downloaded = (name) => (IMPORTED.has(name) ? name : `${name}.download.mjs`
|
|
|
81
83
|
const BUNDLED = SCRIPTS.every((name) => existsSync(join(HERE, name)));
|
|
82
84
|
const PLACEHOLDER = `wt_${"0".repeat(43)}`;
|
|
83
85
|
const say = (line = "") => process.stdout.write(`${line}\n`);
|
|
86
|
+
// A reader that closed the pipe (`worktrust status | head`) wants no more: stop quietly, never an EPIPE stack (0.6.16, audit L4).
|
|
87
|
+
process.stdout.on("error", (error) => { if (error?.code === "EPIPE") process.exit(0); throw error; });
|
|
84
88
|
const fail = (line) => { process.stderr.write(`worktrust: ${line}\n`); process.exit(1); };
|
|
85
89
|
const OS = { darwin: "macos", win32: "windows", linux: "linux" }[platform()] ?? "other";
|
|
86
90
|
const OS_NAME = { macos: "macOS", windows: "Windows", linux: "Linux", other: "this system" }[OS];
|
|
@@ -530,6 +534,7 @@ async function update() {
|
|
|
530
534
|
if (has("dry-run")) { say(" Dry run: nothing was written and nothing was sent."); return; }
|
|
531
535
|
if (!(await ask("Continue?"))) { say(" Stopped. Nothing was written."); return; }
|
|
532
536
|
ensureHome();
|
|
537
|
+
const previousVersion = stableVersion();
|
|
533
538
|
copyFileSync(fileURLToPath(import.meta.url), STABLE);
|
|
534
539
|
say(` ✓ ${STABLE} is now ${CLI_VERSION}`);
|
|
535
540
|
// Out first, then in: an app that held a key of its own (a hand-made entry) moves onto the bridge.
|
|
@@ -540,6 +545,7 @@ async function update() {
|
|
|
540
545
|
// Codex installed since, or a No said at connect: the one question, asked again (or "already set").
|
|
541
546
|
await offerCodex();
|
|
542
547
|
await offerAntigravity();
|
|
548
|
+
await offerRebuild(previousVersion, paths, key);
|
|
543
549
|
say();
|
|
544
550
|
say(" Done. Quit your AI apps completely and open them again; the CLI version shows in WorkTrust");
|
|
545
551
|
say(" under Sources → CLI once one of them has spoken. A key an app held by hand still works until you end it there.");
|
|
@@ -576,7 +582,8 @@ async function connect() {
|
|
|
576
582
|
say(` + ${join(HOME_DIR, "log-session.mjs")} and ${join(HOME_DIR, "count-behaviour.mjs")}${BUNDLED ? " (from this package, not updated from the network)" : ""}`);
|
|
577
583
|
say(` + ${join(homedir(), ".claude", "settings.json")} → SessionStart, SessionEnd and PreCompact run it`);
|
|
578
584
|
say();
|
|
579
|
-
say(" Sent to WorkTrust afterwards: durations, token counts, model names, a layer keyword, counts
|
|
585
|
+
say(" Sent to WorkTrust afterwards: durations, token counts, model names, a layer keyword, counts,");
|
|
586
|
+
say(" and, to match the work to GitHub, the repository's owner/name and the commit hashes.");
|
|
580
587
|
say(" Never sent: prompts, answers, code, file paths, commit messages.");
|
|
581
588
|
say();
|
|
582
589
|
if (has("dry-run")) { say(" Dry run: nothing was written and nothing was sent."); return; }
|
|
@@ -628,24 +635,52 @@ async function connect() {
|
|
|
628
635
|
* Enter, --yes and a closed terminal all mean No; `--history` says yes in advance. What goes is hours
|
|
629
636
|
* and tokens per day, filed as history (never verified hours, never text).
|
|
630
637
|
*/
|
|
631
|
-
async function offerHistory(paths, token, devicePem, url = MCP) {
|
|
638
|
+
async function offerHistory(paths, token, devicePem, url = MCP, rebuild = false) {
|
|
632
639
|
const env = { WORKTRUST_MCP_URL: url, ...(devicePem ? { WORKTRUST_DEVICE_KEY: devicePem } : {}) };
|
|
633
640
|
const counted = await run(paths["log-session.mjs"], ["--history", "--dry-run", "--summary"], token, true, env);
|
|
634
641
|
let found = null;
|
|
635
642
|
try { found = JSON.parse(counted.out.trim().split("\n").pop()); } catch { /* no line: nothing to offer */ }
|
|
636
643
|
say();
|
|
637
|
-
if (!found?.sessions) { say("
|
|
638
|
-
say(` This computer holds ${found.sessions} earlier
|
|
644
|
+
if (!found?.sessions) { say(rebuild ? " Your AI apps hold no earlier sessions on this computer to re-measure." : " Your AI apps hold no earlier sessions on this computer to send."); return; }
|
|
645
|
+
say(` This computer holds ${found.sessions} earlier sessions of your AI apps · ${found.hours} measured hours · ${found.first} … ${found.last}.`);
|
|
646
|
+
// REBUILD (owner, 2026-10-07): the plan per AI app, in days and hours, and what it replaces, before the one question. Enter is No.
|
|
647
|
+
if (rebuild) {
|
|
648
|
+
for (const row of found.clients ?? []) say(` ${row.client.padEnd(12)} ${String(row.days).padStart(4)} days ${String(row.hours).padStart(7)} h ${row.first} … ${row.last}`);
|
|
649
|
+
say(" Re-measured with the current rules (what the seconds were, your own turns, tokens with the cache, every AI app above).");
|
|
650
|
+
say(" WorkTrust REPLACES this computer's earlier lines for those days and apps: each earlier line is withdrawn and kept,");
|
|
651
|
+
say(" never deleted, so a day is counted once. Other computers, web sources and days not listed are not touched.");
|
|
652
|
+
}
|
|
639
653
|
if (has("dry-run")) { say(" Dry run: nothing was sent."); return; }
|
|
640
|
-
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
|
-
const sent = await run(paths["log-session.mjs"], ["--history"], token, true, env);
|
|
642
|
-
say(sent.code === 0 ? " ✓
|
|
654
|
+
if (!has("history") && !(rebuild ? await ask("Rebuild the history (replaces this computer's earlier lines for these days)?", false) : await ask("Send these as history (hours and tokens per day; never text)?", false))) { say(rebuild ? " Not sent. Rebuild later with: npx worktrust@latest history --rebuild" : " Not sent. Send them later with: npx worktrust@latest history"); return; }
|
|
655
|
+
const sent = await run(paths["log-session.mjs"], ["--history", ...(rebuild ? ["--rebuild"] : [])], token, true, env);
|
|
656
|
+
if (rebuild) say(sent.code === 0 ? " ✓ Rebuilt. This computer's earlier lines for those days were replaced; the record shows each replacement." : " Not all of it arrived. Run npx worktrust@latest history --rebuild again: a day already rebuilt is counted once.");
|
|
657
|
+
else say(sent.code === 0 ? " ✓ Sent as history. It shows in WorkTrust as earlier work, never as verified hours." : " Not all of it arrived. Run npx worktrust@latest history again: a line already received is kept once.");
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** The version stamped in the stable copy in ~/.worktrust, the CLI this computer ran before an update; null without one. */
|
|
661
|
+
const stableVersion = () => { try { return /const CLI_VERSION = "([^"]+)";/.exec(readFileSync(STABLE, "utf8"))?.[1] ?? null; } catch { return null; } };
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* OFFERED ONCE, BY `update`, IN PLAIN WORDS (owner, 2026-10-07): when the computer's earlier lines were made by an earlier CLI
|
|
665
|
+
* (the version the stable copy carried before this update). The CLI cannot read the record, so it is an offer, not a
|
|
666
|
+
* finding: the plan is shown and the question asked by the same path as `history --rebuild`; Enter is No. The same
|
|
667
|
+
* version updated again offers nothing.
|
|
668
|
+
*/
|
|
669
|
+
async function offerRebuild(previousVersion, paths, key) {
|
|
670
|
+
if (!previousVersion || previousVersion === CLI_VERSION) return;
|
|
671
|
+
say();
|
|
672
|
+
say(` This computer's earlier lines in WorkTrust were measured by worktrust ${previousVersion}. Version ${CLI_VERSION} measures`);
|
|
673
|
+
say(" differently (what the seconds were, your own turns, tokens with the cache, more AI apps). You can re-measure the");
|
|
674
|
+
say(" sessions still on this computer and have WorkTrust replace this computer's earlier lines, day by day, never counting");
|
|
675
|
+
say(" a day twice. Later: npx worktrust@latest history --rebuild");
|
|
676
|
+
if (!(await ask("Show the plan and rebuild now?", false))) { say(" Not now."); return; }
|
|
677
|
+
await offerHistory(paths, key.token, key.device ?? null, key.url, true);
|
|
643
678
|
}
|
|
644
679
|
|
|
645
680
|
async function history() {
|
|
646
681
|
const key = await freshKey();
|
|
647
682
|
if (!key) fail("this computer is not coupled through the key file. Run npx worktrust first.");
|
|
648
|
-
await offerHistory(await scripts(), key.token, key.device ?? null, key.url);
|
|
683
|
+
await offerHistory(await scripts(), key.token, key.device ?? null, key.url, has("rebuild"));
|
|
649
684
|
}
|
|
650
685
|
|
|
651
686
|
/**
|
|
@@ -840,6 +875,8 @@ function help() {
|
|
|
840
875
|
say(" --replace yes in advance to coupling a computer that is already coupled");
|
|
841
876
|
say(" worktrust update this computer onto the newest CLI: same key, no new pairing");
|
|
842
877
|
say(" worktrust history send this computer's earlier sessions as history (asks first)");
|
|
878
|
+
say(" --rebuild re-measure them with the current rules; WorkTrust replaces this computer's earlier lines,");
|
|
879
|
+
say(" day by day, never counting a day twice (shows the plan per AI app, asks first; --dry-run: the plan only)");
|
|
843
880
|
say(" worktrust disconnect take WorkTrust out of every AI app here (asks first)");
|
|
844
881
|
say(" worktrust status what is coupled here");
|
|
845
882
|
say(" worktrust preserve keep your AI history: the report, then the settings and a local archive, each on your yes");
|