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 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 the Claude Code and Codex sessions already on
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 and the session databases of Hermes, Goose, OpenCode and OpenClaw on this computer to measure durations and token counts. |
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** | Durations, token counts, model names, one layer keyword (frontend, backend …), counts of how you work. |
209
- | **Never sends** | Prompts, answers, code, file names or paths, commit messages, branch or project names. The door has no field for them and refuses a submission that carries them. |
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 --summary one JSON line: sessions, hours, first and last month (sends nothing)
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. No prompt, no answer, no file path, no commit
42
- * message, no branch, no project name. File paths ARE read locally to decide one layer keyword,
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.split("/").at(-1).replace(/\.jsonl$/, "")}`, 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.split("/").at(-4); return [...antigravityLines(lines, { id, ...antigravityContext(id) })]; }
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
- // WHICH STRETCH, HOW STEERED (0.6.10): a hash of the session id (the file name) and the day, the same on any computer, so a
528
- // restored backup is never counted twice; `remote` only when the session runs over SSH, otherwise nothing is said.
529
- const stretchRef = (file, day) => createHash("sha256").update(`worktrust-stretch|${String(file).split(/[\\/]/).pop().replace(/\.jsonl?$/, "")}|${day}`).digest("base64url");
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
- const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "import_history", arguments: { entries: batch.map((entry) => { const { commits, ...rest } = entry; void commits; return rest; }) } } });
589
- const response = await fetch(target.url, { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${target.token}`, ...clientOf(source), ...proofFor(target.url, body) }, body });
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
- return entries.sort((a, b) => a.at.localeCompare(b.at));
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
- 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 })); await exitFlushed(0); }
868
- console.log(`${entries.length} day-stretches · ${hours.toFixed(1)} measured hours · ${months.join(", ")}`);
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("\ndry run — nothing sent. Add --history without --dry-run to import them.");
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
- let filed = 0;
906
- /** Measured days the live door will no longer take. They are not dropped; they are filed. */
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 has been written since we last read this one — no need to open it at all. This is what
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 = /^(codex|antigravity|hermes|goose|opencode|openclaw):/.exec(String(entry.id))?.[1];
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 takes work as it happens and refuses anything
928
- // older than two days — which is right, and used to mean a machine that was closed for a
929
- // fortnight lost the fortnight. The same measured numbers go through the history door instead:
930
- // recorded, marked as history, standing as history stands. Late is not the same as untrue.
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 (through !== null) state[entry.id] = { through: new Date(through).toISOString() };
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 one batch per two hundred, exactly as `--history` sends them. A resend
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
- if (process.env.WORKTRUST_HOOK_DEBUG) console.error(`worktrust: ${error.message}`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worktrust",
3
- "version": "0.6.15",
3
+ "version": "0.6.17",
4
4
  "description": "Couple this computer to WorkTrust: approve a code in your browser, and every AI app here reports what you built. Metadata only, no dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 = new Date(Number(message.timestamp) * 1000).toISOString();
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 = new Date(ms(message.at)).toISOString();
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 = new Date(Number(message.at)).toISOString();
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 = new Date(bubble.at).toISOString();
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.includes("/api/mcp")) { 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); };
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" && /\/api\/mcp\b/.test(server.url) && /worktrust/i.test(new URL(server.url).host);
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));
@@ -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
- export const CODEX_ROLLOUT = /(^|\/)rollout-\d{4}-\d{2}-\d{2}T[\d-]+-[0-9a-f-]{36}\.jsonl$/;
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 = /(^|\/)brain\/[^/]+\/\.system_generated\/logs\/transcript\.jsonl$/;
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.15";
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(" No earlier Claude Code, Codex or Antigravity sessions on this computer to send."); return; }
638
- say(` This computer holds ${found.sessions} earlier Claude Code / Codex / Antigravity sessions · ${found.hours} measured hours · ${found.first} … ${found.last}.`);
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 ? " ✓ 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.");
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");