worktrust 0.6.16 → 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
@@ -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
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
@@ -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
@@ -580,7 +577,7 @@ function payloadFor(stretch) {
580
577
  * exactly what landed. `announce` because one caller is a person watching a terminal and the other
581
578
  * is a hook holding a session's exit open.
582
579
  */
583
- async function sendHistory(door, entries, announce = false) {
580
+ async function sendHistory(door, entries, announce = false, rebuild = false) {
584
581
  let accepted = 0;
585
582
  // A Codex entry goes through Codex's own coupling where one exists (see codexDoor), the rest
586
583
  // through the door given: the record files a line under the coupling that sent it. Antigravity's
@@ -590,9 +587,10 @@ async function sendHistory(door, entries, announce = false) {
590
587
  if (group.length === 0 || !target) continue;
591
588
  for (let i = 0; i < group.length; i += 200) {
592
589
  const batch = group.slice(i, i + 200);
593
- // History carries no shas: an imported line forms no session, and the join is the live route's.
594
- 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; }) } } });
595
- 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 });
596
594
  const text = await response.text();
597
595
  if (announce) console.log(`batch ${Math.floor(i / 200) + 1}: ${response.status} ${text.slice(0, 200).replace(/\\n/g, " ")}`);
598
596
  if (!response.ok) break;
@@ -616,7 +614,7 @@ const proofFor = (url, body) => {
616
614
  * on this computer, and the door names a coupling's clients by the user-agent it hears (mcp_identify's
617
615
  * agents_seen). An Antigravity line names Antigravity: the product name, nothing of the work.
618
616
  */
619
- 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)` } : {});
620
618
 
621
619
  async function send(door, payload) {
622
620
  const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "log_work", arguments: payload } });
@@ -870,17 +868,18 @@ if (flag("history")) {
870
868
  const entries = historyEntries(historyFloor);
871
869
  const hours = entries.reduce((sum, entry) => sum + (entry.seconds ?? 0), 0) / 3600;
872
870
  const months = [...new Set(entries.map((entry) => entry.at.slice(0, 7)))].sort();
873
- // ONE LINE FOR `npx worktrust` TO ASK WITH (2026-10-03): sessions, hours, first and last month; nothing sent.
874
- 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); }
875
- 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" : ""}`);
876
875
  if (DRY) {
877
876
  // One example in full: the shape is the argument for trusting it, and a summary hides it.
878
877
  if (entries[0]) console.log(`\nthe oldest of them, in full:\n${JSON.stringify(entries[0], null, 1)}`);
879
- 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.`);
880
879
  await exitFlushed(0);
881
880
  }
882
881
  // A batch the door refused is a failure the caller must see, not a quiet zero.
883
- 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);
884
883
  }
885
884
 
886
885
  const input = hookInput();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worktrust",
3
- "version": "0.6.16",
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": {
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.16";
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");
@@ -532,6 +534,7 @@ async function update() {
532
534
  if (has("dry-run")) { say(" Dry run: nothing was written and nothing was sent."); return; }
533
535
  if (!(await ask("Continue?"))) { say(" Stopped. Nothing was written."); return; }
534
536
  ensureHome();
537
+ const previousVersion = stableVersion();
535
538
  copyFileSync(fileURLToPath(import.meta.url), STABLE);
536
539
  say(` ✓ ${STABLE} is now ${CLI_VERSION}`);
537
540
  // Out first, then in: an app that held a key of its own (a hand-made entry) moves onto the bridge.
@@ -542,6 +545,7 @@ async function update() {
542
545
  // Codex installed since, or a No said at connect: the one question, asked again (or "already set").
543
546
  await offerCodex();
544
547
  await offerAntigravity();
548
+ await offerRebuild(previousVersion, paths, key);
545
549
  say();
546
550
  say(" Done. Quit your AI apps completely and open them again; the CLI version shows in WorkTrust");
547
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.");
@@ -631,24 +635,52 @@ async function connect() {
631
635
  * Enter, --yes and a closed terminal all mean No; `--history` says yes in advance. What goes is hours
632
636
  * and tokens per day, filed as history (never verified hours, never text).
633
637
  */
634
- async function offerHistory(paths, token, devicePem, url = MCP) {
638
+ async function offerHistory(paths, token, devicePem, url = MCP, rebuild = false) {
635
639
  const env = { WORKTRUST_MCP_URL: url, ...(devicePem ? { WORKTRUST_DEVICE_KEY: devicePem } : {}) };
636
640
  const counted = await run(paths["log-session.mjs"], ["--history", "--dry-run", "--summary"], token, true, env);
637
641
  let found = null;
638
642
  try { found = JSON.parse(counted.out.trim().split("\n").pop()); } catch { /* no line: nothing to offer */ }
639
643
  say();
640
- if (!found?.sessions) { say(" Your AI apps hold no earlier sessions on this computer to send."); return; }
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; }
641
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
+ }
642
653
  if (has("dry-run")) { say(" Dry run: nothing was sent."); return; }
643
- if (!has("history") && !(await ask("Send these as history (hours and tokens per day; never text)?", false))) { say(" Not sent. Send them later with: npx worktrust@latest history"); return; }
644
- const sent = await run(paths["log-session.mjs"], ["--history"], token, true, env);
645
- 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);
646
678
  }
647
679
 
648
680
  async function history() {
649
681
  const key = await freshKey();
650
682
  if (!key) fail("this computer is not coupled through the key file. Run npx worktrust first.");
651
- 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"));
652
684
  }
653
685
 
654
686
  /**
@@ -843,6 +875,8 @@ function help() {
843
875
  say(" --replace yes in advance to coupling a computer that is already coupled");
844
876
  say(" worktrust update this computer onto the newest CLI: same key, no new pairing");
845
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)");
846
880
  say(" worktrust disconnect take WorkTrust out of every AI app here (asks first)");
847
881
  say(" worktrust status what is coupled here");
848
882
  say(" worktrust preserve keep your AI history: the report, then the settings and a local archive, each on your yes");