worktrust 0.6.11 → 0.6.13

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
@@ -16,6 +16,12 @@ from WorkTrust's own site:
16
16
  curl -fsSO https://app.worktrust.io/counter/worktrust.mjs && node worktrust.mjs
17
17
  ```
18
18
 
19
+ **No account? Keep your history anyway.** Claude Code and Gemini CLI delete their sessions after 30
20
+ days by default, and a deleted session can never be measured again. `npx worktrust@latest preserve` shows what
21
+ each AI app on this computer keeps, keeps it longer on your yes, and writes a local, metadata-only
22
+ record. It needs no account and opens no network connection: it stays on this computer until you
23
+ yourself run a command that sends. See [Keep your history](#keep-your-history).
24
+
19
25
  ## What happens
20
26
 
21
27
  1. **A plan, then a question.** The command lists every AI app it found on this computer and the
@@ -36,7 +42,9 @@ curl -fsSO https://app.worktrust.io/counter/worktrust.mjs && node worktrust.mjs
36
42
  this computer and asks `Send these as history? [y/N]`. Enter sends nothing. What goes is hours
37
43
  and tokens per day, never text, and WorkTrust shows it as earlier work, never as verified hours.
38
44
  Nothing from before the coupling is ever sent without that yes: the session hook starts at the
39
- moment of coupling.
45
+ moment of coupling. Beside the seconds, each day says what they were (the model answering, tools
46
+ running, your own turns, pauses) and what the session's subagents did on their own clocks, as
47
+ counts; a subagent's hour is shown as an agent-hour and never as one of yours.
40
48
  5. **Codex only if you say yes.** With Codex on this computer it asks `Let Codex wake the session
41
49
  hook? [y/N]` and, on a yes, sets the one `notify` line in `~/.codex/config.toml` (the file is
42
50
  kept as `config.toml.worktrust-backup` first). Another program's `notify` is never replaced.
@@ -70,13 +78,107 @@ curl -fsSO https://app.worktrust.io/counter/worktrust.mjs && node worktrust.mjs
70
78
  open, starts the session hook in the background, at most once an hour.
71
79
 
72
80
  ```
73
- npx worktrust history send this computer's earlier sessions later (asks first)
81
+ npx worktrust@latest history send this computer's earlier sessions later (asks first)
74
82
  npx worktrust codex let Codex wake the session hook (asks first)
75
83
  npx worktrust antigravity let Antigravity wake the session hook (asks first)
76
- npx worktrust status what is coupled here
77
- npx worktrust disconnect take it out again (shows the plan, asks first)
84
+ npx worktrust@latest status what is coupled here
85
+ npx worktrust@latest disconnect take it out again (shows the plan, asks first)
86
+ ```
87
+
88
+ ## Keep your history
89
+
90
+ **Why.** Your AI apps decide how long your history lives, and several delete it on their own schedule:
91
+
92
+ | App | Kept by default | `preserve --apply` |
93
+ |---|---|---|
94
+ | Claude Code | 30 days, then deleted without notice | sets `cleanupPeriodDays` to 3650 |
95
+ | Gemini CLI | 30 days | turns `general.sessionRetention` off |
96
+ | Hermes Agent | 90 days after a session ended | prints the line to add (`sessions.auto_prune: false`) |
97
+ | Codex CLI | no deletion documented | nothing to change |
98
+ | Cursor, Copilot Chat, OpenCode, Goose, LM Studio | not documented | left alone |
99
+
100
+ (From each vendor's own documentation, checked 6 October 2026. Copilot Chat also syncs its sessions
101
+ to GitHub by default: `chat.sessionSync.enabled`.) A deleted session is a day of work that can never
102
+ be shown again, by WorkTrust or by anyone.
103
+
104
+ **Local, and yours.** `preserve` works on this computer only. It needs no WorkTrust account and no
105
+ key, and it opens no network connection; `check-cli-package` refuses the package if `preserve.mjs` so
106
+ much as imports a network module. What it keeps stays here **until you yourself run a command that
107
+ sends**: `npx worktrust` to couple this computer, then `npx worktrust@latest history`, which shows what it
108
+ would send and asks `[y/N]` first. Without those two, nothing ever leaves.
109
+
110
+ ```
111
+ npx worktrust@latest preserve every step: the report, then asks to keep the settings and a local archive
112
+ npx worktrust@latest preserve --apply the settings step only: asks [y/N] per change, backs the file up first
113
+ npx worktrust@latest preserve --archive [dir] a local record of the measured days (default ~/AI-Evidence)
114
+ npx worktrust@latest preserve --verify [dir] check that record: was anything changed afterwards?
115
+ npx worktrust@latest preserve --summary [dir] hours, tokens per model, cache share and parallel sessions, from that record
78
116
  ```
79
117
 
118
+ **Always the newest, from any terminal or agent.** Run it as `npx worktrust@latest …`: npx then asks npm for the
119
+ newest version every time, where a bare `npx worktrust` may reuse a copy npm cached earlier. Which terminal or AI
120
+ agent runs it makes no difference: `preserve` writes to your own user's places (the apps' settings files and
121
+ `~/AI-Evidence`), so every terminal and agent on this computer under your user reaches the same archive, and lines
122
+ from different versions sit side by side, each naming the version that measured it. Another user on the same computer
123
+ gets an archive of their own. A coupled computer's session hook runs from its own copy in `~/.worktrust` and changes
124
+ only with `npx worktrust@latest update`; the WorkTrust app says when a newer version is out.
125
+
126
+ **One command walks it all.** `npx worktrust@latest preserve` shows the report, asks `Keep these settings? [y/N]`
127
+ (then does what `--apply` does), asks `Keep a local archive in ~/AI-Evidence? [y/N]` (then does what `--archive`
128
+ does; an archive already there is simply added to), and with an archive prints its check and its summary. Enter is
129
+ No. Without a terminal, and without `--yes`, it changes nothing. The flags each do one step, for scripts.
130
+
131
+ **Later, into WorkTrust, if you want.** Because `--apply` keeps the apps' own session files, coupling
132
+ this computer months from now still finds them: `npx worktrust@latest history` measures them the same way the
133
+ live hook does, and they arrive as history, labelled as such, never as work WorkTrust watched happen.
134
+ The archive is a plain, documented format made for the same purpose; importing it directly is planned,
135
+ not built.
136
+
137
+ **What `--archive` keeps, per AI app per day.** The same measurement the session hook makes, and
138
+ nothing it does not:
139
+
140
+ | Field | What it is |
141
+ |---|---|
142
+ | `client` | the AI app (`claude`, `codex`, `cursor`, `copilot`, `hermes` …) |
143
+ | `day`, `started_at`, `ended_at` | the day, and when the measured stretch began and ended |
144
+ | `seconds` | measured time: every gap between stamped events up to five minutes |
145
+ | `model_seconds`, `tool_seconds`, `human_seconds`, `idle_seconds` | what those seconds were: the model answering, tools or an agent run, your own turns, and what a cap cut off |
146
+ | `tokens_in`, `tokens_out`, `tokens_cache_read`, `tokens_cache_write` | token counts, when the app records them |
147
+ | `agent_runs`, `agent_seconds`, `agent_peak` | subagents: how many ran, their own time, the most open at once (never added to `seconds`) |
148
+ | `model` | the model's name as the app reported it |
149
+ | `kind`, `layer`, `layers` | the kind of work (built, changed, researched) and the area it touched (front end, back end, data …), as the session hook names them |
150
+ | `exchanges` | how many times you and the model took turns |
151
+ | `commits` | the hashes of commits made in the stretch, never a message: what ties AI time to work in the repository |
152
+ | `steered_from` | `remote` when the session ran over SSH |
153
+ | `duration_basis`, `token_basis`, `turn_basis` | how the seconds, tokens and turns were measured |
154
+ | `project` | a one-way hash of the project's name: which days belong together, never which project |
155
+ | `stretch_ref` | a hash of the session's id and the day, so a stretch is archived once |
156
+ | `device_id`, `profile_id` | which computer and which user profile on it, as one-way hashes (the machine's own id never appears) |
157
+ | `collector_version` | the CLI version that measured it |
158
+ | `seq`, `prev`, `hash` | the line's place, the hash of the line before it, and its own hash |
159
+
160
+ A value the app did not record is left out, never written as 0. Each day also gets a proof in
161
+ `proofs/<day>.json` (the day's line count, a root hash over its lines, and a signature with this
162
+ computer's device key when it is coupled), and `manifest.json` holds the count and the last hash, so a
163
+ removed line is caught. The manifest names the computer and profile too.
164
+
165
+ **Never kept:** a prompt, an answer, code, a file or its path, a branch, a commit message, a title,
166
+ terminal output, or anything typed. The text in your sessions is read only to find clocks, model names
167
+ and counts, exactly as the session hook reads it.
168
+
169
+ - **What `--apply` writes.** Only the settings in the table above, each on a typed `y`. The file is
170
+ copied to `settings.json.worktrust-backup-<date>` first, every other setting stays as it was, and a
171
+ value you set higher is never lowered. Hermes's YAML is never edited.
172
+ - **Today is archived tomorrow.** A day goes into the archive once it is over; a later run only adds
173
+ lines and never rewrites one.
174
+ - **What `--summary` counts.** From the archive, after its chain checks out: per month the measured
175
+ hours, the tokens per model and the share of input served from the cache (where tokens go, and so
176
+ where they can be saved), and the days on which two or more sessions ran in one project at the same
177
+ time, with the most open at once and their session time against the wall-clock time they covered.
178
+ - **The archive is yours** to keep, copy, back up or delete. The free kit on
179
+ [worktrust.io/preserve](https://worktrust.io/preserve) is this package plus a guideline, for teams
180
+ that want the same record without WorkTrust.
181
+
80
182
  ## Where the key lives, and why it only works here
81
183
 
82
184
  The key is kept in **one file only you can read**: `~/.worktrust/key.json` (mode 600, in a folder of
@@ -108,9 +210,10 @@ which would delete every password on the computer, and many people rightly keep
108
210
 
109
211
  ## Why you can check it
110
212
 
111
- - **No dependencies and no install scripts.** Four plain files, about 2,600 lines in total, readable
213
+ - **No dependencies and no install scripts.** Plain files, readable
112
214
  in an afternoon: `worktrust.mjs` (this command), `setup-mcp.mjs` (writes the MCP entries),
113
- `log-session.mjs` (the session hook) and `count-behaviour.mjs` (the local counter).
215
+ `log-session.mjs` (the session hook), `count-behaviour.mjs` (the local counter) and `preserve.mjs`
216
+ (keep your history), with the readers they import.
114
217
  - **Nothing is downloaded at run time.** Everything that runs is in the package. The counter is
115
218
  pinned: it is not replaced from the network; a new one comes with a new version of this package.
116
219
  - **Provenance.** Every version is built and published from a public repository by GitHub Actions
@@ -123,7 +226,7 @@ which would delete every password on the computer, and many people rightly keep
123
226
 
124
227
  ## A computer that is already coupled, or shared
125
228
 
126
- - `npx worktrust connect` on a computer that is already coupled says to which account (masked) and
229
+ - `npx worktrust@latest connect` on a computer that is already coupled says to which account (masked) and
127
230
  asks `Couple it again? [y/N]`. Enter and `--yes` mean No; `--replace` says yes in advance.
128
231
  - The browser page that approves it shows, large, which WorkTrust account is signed in, with
129
232
  **Use another account**: the computer goes to whoever approves it there.
@@ -132,13 +235,13 @@ which would delete every password on the computer, and many people rightly keep
132
235
  - When the new account is a different one, the old account sees a notice that its computer moved,
133
236
  with the computer's name and the moment, never the other account; no earlier work on this
134
237
  computer is offered to the new account, only work from the moment of coupling.
135
- - `npx worktrust status` shows the account this computer sends to.
238
+ - `npx worktrust@latest status` shows the account this computer sends to.
136
239
  - Different people on one computer with their own OS user each couple their own: everything lives
137
240
  in that user's home folder, and the accounts never meet.
138
241
 
139
242
  ## Undo
140
243
 
141
- `npx worktrust disconnect` removes the WorkTrust entries (Hermes's hook with them), the hook (and Codex's `notify` line and
244
+ `npx worktrust@latest disconnect` removes the WorkTrust entries (Hermes's hook with them), the hook (and Codex's `notify` line and
142
245
  Antigravity's `worktrust` hook when WorkTrust set them) and the key file, and empties
143
246
  `~/.worktrust` but for one small note of which account it fed (masked), so that a later coupling by
144
247
  another account offers none of this computer's earlier work.
package/log-session.mjs CHANGED
@@ -107,7 +107,6 @@ async function refreshCounter(origin, announce) {
107
107
  } catch { return false; }
108
108
  }
109
109
 
110
-
111
110
  /** A pause longer than this is not work, so it is not counted. Five minutes. */
112
111
  const IDLE_CAP = 300;
113
112
  /**
@@ -129,6 +128,14 @@ const MIN_SECONDS = 60;
129
128
  * guessing where one piece of work ended and the next began.
130
129
  */
131
130
  const MAX_SECONDS = 28800;
131
+ /** The door's ceilings for the layers (a day) and for a day's subagents: the same bounds its columns check. */
132
+ const DAY_SECONDS = 86400;
133
+ /** A tool gap is tool time up to thirty minutes (proposed, owner 2026-10-06); past that it is a wait, counted as idle. */
134
+ const TOOL_CAP = 1800;
135
+ /** Tools whose result waits for the person: the gap to it is the person's time. Names only; nothing of the call is read. */
136
+ const WAITS_FOR_PERSON = new Set(["AskUserQuestion", "ExitPlanMode"]);
137
+ const AGENT_RUNS_MAX = 10000;
138
+ const AGENT_SECONDS_MAX = 2592000;
132
139
 
133
140
  /**
134
141
  * WHEN THE HOOK RUNS. SessionEnd alone was a bet that sessions end, and a session that stays open
@@ -269,7 +276,6 @@ function codexCoupling() {
269
276
  return null;
270
277
  }
271
278
 
272
-
273
279
  /** Which layer a set of paths touched — the same plain reading the connector uses for classes. */
274
280
  const LAYER_RULES = [
275
281
  ["database", /(^|\/)migrations?\/|\.sql$|(^|\/)(schema|seed)s?\.(prisma|sql|ts|rb)$/i],
@@ -332,7 +338,7 @@ function stretchesOf(file, since) {
332
338
  const at = Date.parse(line.timestamp);
333
339
  if (!Number.isFinite(at)) continue;
334
340
  if (since && at <= since) continue;
335
- messages.push({ at, type: line.type, usage: line.message?.usage ?? null, model: line.message?.model ?? null, meta: line.isMeta === true, content: line.message?.content ?? null });
341
+ messages.push(messageOf(line, at));
336
342
  }
337
343
  if (messages.length === 0) return [];
338
344
  messages.sort((a, b) => a.at - b.at);
@@ -342,8 +348,76 @@ function stretchesOf(file, since) {
342
348
  if (!byDay.has(day)) byDay.set(day, []);
343
349
  byDay.get(day).push(message);
344
350
  }
345
- return [...byDay.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([day, ofDay]) => { const stretch = measure(cwd, ofDay); return stretch && { ...stretch, ref: stretchRef(file, day), steered }; }).filter(Boolean);
351
+ const agents = agentsByDay(file, since);
352
+ return [...byDay.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([day, ofDay]) => { const stretch = measure(cwd, ofDay); return stretch && { ...stretch, ref: stretchRef(file, day), steered, agents: agents.get(day) ?? null }; }).filter(Boolean);
353
+ }
354
+
355
+ /** One transcript line as the measure reads it: the clock, the role, what KIND of message it is (for the layers), its counts. */
356
+ const messageOf = (line, at) => {
357
+ const content = line.message?.content ?? null;
358
+ const blocks = Array.isArray(content) ? content : [];
359
+ return {
360
+ at, type: line.type, usage: line.message?.usage ?? null, model: line.message?.model ?? null, meta: line.isMeta === true, content,
361
+ // A tool's answer comes back on the user role; a line a reader marks `agentic` is an agent run (Copilot's asked → completed).
362
+ kind: line.type === "assistant" ? "assistant" : blocks.some((block) => block && block.type === "tool_result") ? "tool_result" : "user",
363
+ toolUse: line.type === "assistant" && blocks.some((block) => block && block.type === "tool_use"),
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.
365
+ waitsForPerson: line.type === "assistant" && blocks.some((block) => block && block.type === "tool_use" && WAITS_FOR_PERSON.has(String(block.name))),
366
+ agentic: line.agentic === true,
367
+ };
368
+ };
369
+
370
+ /**
371
+ * SUBAGENTS AS AGENT TIME, NEVER AS HOURS (owner, 2026-10-06). Claude Code writes each subagent's transcript beside its
372
+ * session (`<session>/subagents/*.jsonl`: 279 of them beside 48 sessions on the computer this was built on), and the
373
+ * sweep read only the sessions, so delegated work was invisible. Now each run is read for its clocks and sent as
374
+ * METADATA beside the parent's day: how many runs started that day, their own measured seconds (the same gap rule, the
375
+ * same cap), and how many were open at once at the busiest moment. None of it enters `seconds`: four agents working an
376
+ * hour are four agent-hours and one hour of the person's, and the record says which is which. Only Claude Code keeps
377
+ * subagents this way; every other client answers an empty map.
378
+ */
379
+ function subagentFiles(file) {
380
+ if (!/\.jsonl$/.test(String(file)) || CODEX_ROLLOUT.test(file) || ANTIGRAVITY_TRANSCRIPT.test(file) || DATABASE_SESSION.test(file)) return [];
381
+ const dir = join(String(file).replace(/\.jsonl$/, ""), "subagents");
382
+ try { return readdirSync(dir).filter((name) => name.endsWith(".jsonl")).map((name) => join(dir, name)); } catch { return []; }
383
+ }
384
+ function agentsByDay(file, since) {
385
+ const byDay = new Map();
386
+ for (const sub of subagentFiles(file)) {
387
+ const lines = parsedLines(sub);
388
+ if (!lines) continue;
389
+ const stamps = [];
390
+ let started = Infinity;
391
+ for (const line of lines) {
392
+ if (!line.timestamp || (line.type !== "user" && line.type !== "assistant")) continue;
393
+ const at = Date.parse(line.timestamp);
394
+ if (!Number.isFinite(at)) continue;
395
+ started = Math.min(started, at);
396
+ if (!(since && at <= since)) stamps.push(at);
397
+ }
398
+ // A RUN IS COUNTED ONCE, on the side of the watermark where it STARTED (review, 2026-10-06): a run that straddles the
399
+ // watermark was sent with the day it began and is not a second run on the next sweep; what it did after the
400
+ // watermark stays unsent, which is the safe direction.
401
+ if (stamps.length === 0 || (since && started <= since)) continue;
402
+ stamps.sort((a, b) => a - b);
403
+ // A run is counted on the day it started; its seconds go with it, the same rule as the parent's.
404
+ const day = new Date(stamps[0]).toISOString().slice(0, 10);
405
+ let seconds = 0;
406
+ for (let i = 1; i < stamps.length; i += 1) seconds += Math.min(IDLE_CAP, Math.round((stamps[i] - stamps[i - 1]) / 1000));
407
+ if (!byDay.has(day)) byDay.set(day, { runs: 0, seconds: 0, spans: [] });
408
+ const entry = byDay.get(day);
409
+ entry.runs += 1; entry.seconds += seconds; entry.spans.push([stamps[0], stamps[stamps.length - 1]]);
410
+ }
411
+ for (const entry of byDay.values()) entry.peak = peakOf(entry.spans);
412
+ return byDay;
346
413
  }
414
+ /** The most spans open at one instant; a span that ends where another starts does not overlap it. */
415
+ const peakOf = (spans) => {
416
+ const events = spans.flatMap(([from, to]) => [[from, 1], [to, -1]]).sort((a, b) => a[0] - b[0] || a[1] - b[1]);
417
+ let open = 0, peak = 0;
418
+ for (const [, delta] of events) { open += delta; peak = Math.max(peak, open); }
419
+ return peak;
420
+ };
347
421
 
348
422
  /**
349
423
  * IS THIS A HUMAN TURN? (2026-09-20)
@@ -387,7 +461,35 @@ function measure(cwd, messages) {
387
461
  }
388
462
  const model = [...models.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
389
463
  const tokens = tokensOf(messages);
390
- return { cwd, seconds: measured, ...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, exchanges, model, from: messages[0].at, to: messages[messages.length - 1].at };
465
+ }
466
+
467
+ /**
468
+ * HOW THE SECONDS WERE SPENT (owner, 2026-10-06: "observed time → AI active → agent-directed → verified work activity").
469
+ * `seconds` keeps its exact meaning above; this reads the same gaps a second time and says what each one WAS, by the
470
+ * message that ends it: the model's message (model time, capped as before), a tool's result or an agent run (tool time,
471
+ * NOT capped at five minutes, because a test suite that ran twenty minutes was twenty minutes of the agent's work; bounded
472
+ * by the stretch's own ceiling), or the person's next turn (human time, capped). What a cap cut off is idle, counted as
473
+ * idle and never as work. The four sum to the stretch's span, so nothing is hidden and nothing is double-counted.
474
+ */
475
+ function timeLayers(messages) {
476
+ const split = { model: 0, tool: 0, human: 0, idle: 0 };
477
+ for (let i = 1; i < messages.length; i += 1) {
478
+ const gap = Math.max(0, Math.round((messages[i].at - messages[i - 1].at) / 1000));
479
+ const next = messages[i], prev = messages[i - 1];
480
+ // WAITS FOR THE PERSON ARE NOT TOOL TIME (review, 2026-10-06). A tool's result arrives when the tool is done, or when the
481
+ // person answered a question, approved a plan, or sat on a permission prompt. The first two are named by the tool and
482
+ // counted as human time; the third is invisible in the transcript, so a tool gap is counted as tool time only up to
483
+ // TOOL_CAP, and the rest as idle: a tool runs minutes, a person away from the keyboard does not.
484
+ const tool = (next.kind === "tool_result" || next.agentic || (prev.toolUse && next.kind === "assistant")) && !prev.waitsForPerson;
485
+ // ONE DEFINITION OF A HUMAN TURN (review, 2026-10-06): the same `isHumanTurn` that counts `exchanges`; a user line that
486
+ // is not one (a meta line the client injected, a textless line) is the loop's own and counts as model time.
487
+ const human = !tool && ((prev.waitsForPerson && next.kind === "tool_result") || (next.kind === "user" && !next.meta && isHumanTurn(next.content)));
488
+ const counted = Math.min(tool ? TOOL_CAP : IDLE_CAP, gap);
489
+ split[tool ? "tool" : human ? "human" : "model"] += counted;
490
+ split.idle += gap - counted;
491
+ }
492
+ return split;
391
493
  }
392
494
 
393
495
  /**
@@ -455,6 +557,10 @@ function payloadFor(stretch) {
455
557
  ...(repo ? { repo } : {}),
456
558
  ...(touched.shas.length > 0 ? { commits: touched.shas.slice(0, 50) } : {}),
457
559
  ...(stretch.ref ? { stretch_ref: stretch.ref } : {}), ...(stretch.steered ? { steered_from: stretch.steered } : {}),
560
+ // THE LAYERS (2026-10-06), only on a measured stretch, each bounded by a day: what the seconds were. `seconds` above is untouched.
561
+ ...(stretch.seconds !== null && stretch.layers ? { model_seconds: Math.min(DAY_SECONDS, stretch.layers.model), tool_seconds: Math.min(DAY_SECONDS, stretch.layers.tool), human_seconds: Math.min(DAY_SECONDS, stretch.layers.human), idle_seconds: Math.min(DAY_SECONDS, stretch.layers.idle) } : {}),
562
+ // THE SUBAGENTS, as agent metadata beside the day: runs, their own seconds, the peak open at once. Never in `seconds`.
563
+ ...(stretch.agents && stretch.agents.runs > 0 ? { agent_runs: Math.min(AGENT_RUNS_MAX, stretch.agents.runs), agent_seconds: Math.min(AGENT_SECONDS_MAX, stretch.agents.seconds), agent_peak: Math.min(AGENT_RUNS_MAX, stretch.agents.peak) } : {}),
458
564
  at: new Date(stretch.to).toISOString(),
459
565
  };
460
566
  }
@@ -525,7 +631,7 @@ async function send(door, payload) {
525
631
  * matches commits by, and cutting on the calendar keeps `at` a real instant rather than an
526
632
  * average of one. Younger than the live window is left alone — that is `log_work`'s half.
527
633
  */
528
- function historyEntries(floor = null) {
634
+ function historyEntries(floor = null, all = false) {
529
635
  const entries = [];
530
636
  for (const { file } of allTranscripts()) {
531
637
  const lines = parsedLines(file);
@@ -540,8 +646,9 @@ function historyEntries(floor = null) {
540
646
  if (!Number.isFinite(at)) continue;
541
647
  const day = new Date(at).toISOString().slice(0, 10);
542
648
  if (!byDay.has(day)) byDay.set(day, []);
543
- byDay.get(day).push({ at, type: line.type, usage: line.message?.usage ?? null, model: line.message?.model ?? null, meta: line.isMeta === true, content: line.message?.content ?? null });
649
+ byDay.get(day).push(messageOf(line, at));
544
650
  }
651
+ const agents = agentsByDay(file, null);
545
652
  for (const [day, messages] of byDay) {
546
653
  if (messages.length === 0) continue;
547
654
  messages.sort((a, b) => a.at - b.at);
@@ -549,25 +656,13 @@ function historyEntries(floor = null) {
549
656
  if (floor !== null && messages[0].at < floor) continue;
550
657
  const last = messages[messages.length - 1].at;
551
658
  // The live window belongs to the hook: a stretch that log_work may still report is not history.
552
- if (Date.now() - last < WINDOW_HOURS * 3600_000) continue;
553
- let seconds = 0;
554
- for (let i = 1; i < messages.length; i += 1) seconds += Math.min(IDLE_CAP, Math.round((messages[i].at - messages[i - 1].at) / 1000));
555
- const models = new Map();
556
- let exchanges = 0;
557
- for (const message of messages) {
558
- if (message.type === "user" && !message.meta && isHumanTurn(message.content)) exchanges += 1;
559
- if (message.model) models.set(message.model, (models.get(message.model) ?? 0) + 1);
560
- }
561
- const { tokensIn, tokensOut, cacheRead, cacheWrite, shapeKnown } = tokensOf(messages);
562
- const entry = payloadFor({
563
- cwd, seconds: seconds >= MIN_SECONDS ? Math.min(MAX_SECONDS, seconds) : null,
564
- tokensIn, tokensOut, cacheRead, cacheWrite, shapeKnown, exchanges, model: [...models.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null,
565
- from: messages[0].at, to: last, ref: stretchRef(file, day), steered,
566
- });
659
+ if (!all && Date.now() - last < WINDOW_HOURS * 3600_000) continue;
660
+ // The same measure as a live day (seconds, layers, tokens, turns, model); history differs only in the door it goes through.
661
+ const entry = payloadFor({ ...measure(cwd, messages), ref: stretchRef(file, day), steered, agents: agents.get(day) ?? null });
567
662
  if (CODEX_ROLLOUT.test(file)) entrySource.set(entry, "codex");
568
663
  if (ANTIGRAVITY_TRANSCRIPT.test(file)) entrySource.set(entry, "antigravity");
569
664
  if (DATABASE_SESSION.test(file)) entrySource.set(entry, file.slice(0, file.indexOf(":")));
570
- entryFile.set(entry, file);
665
+ entryFile.set(entry, { file, from: messages[0].at, folder: cwd ? String(cwd).split(/[\\/]/).filter(Boolean).pop() ?? null : null });
571
666
  entries.push(entry);
572
667
  }
573
668
  }
@@ -756,7 +851,9 @@ const door = coupling();
756
851
  const codexDoor = codexCoupling();
757
852
  const doorFor = (entry) => (String(entry?.id ?? "").startsWith("codex:") ? codexDoor ?? door : door);
758
853
  const entrySource = new WeakMap(); // an entry → "codex" or "antigravity" by the client that wrote it; the sends route and name by it
759
- const entryFile = new WeakMap(); // a history entry → its transcript, so `--summary` can count sessions
854
+ const entryFile = new WeakMap(); // a history entry → its transcript (`--summary` counts sessions), first moment and folder name (`--archive-lines`)
855
+ // THE LOCAL ARCHIVE (`npx worktrust preserve --archive`, 2026-10-06): every measured day, live window and floors aside, as JSON lines to preserve.mjs on this computer, which keeps metadata only; nothing is sent.
856
+ if (flag("archive-lines")) { for (const entry of historyEntries(null, true)) console.log(JSON.stringify({ ...entry, client: entrySource.get(entry) ?? "claude", started_at: new Date(entryFile.get(entry).from).toISOString(), folder: entryFile.get(entry).folder })); process.exit(0); }
760
857
  if (!door && !codexDoor && !DRY) { process.exit(0); } // Not coupled on this machine: nothing to do, quietly.
761
858
 
762
859
  if (flag("history")) {
@@ -765,7 +862,7 @@ if (flag("history")) {
765
862
  const hours = entries.reduce((sum, entry) => sum + (entry.seconds ?? 0), 0) / 3600;
766
863
  const months = [...new Set(entries.map((entry) => entry.at.slice(0, 7)))].sort();
767
864
  // ONE LINE FOR `npx worktrust` TO ASK WITH (2026-10-03): sessions, hours, first and last month; nothing sent.
768
- if (flag("summary")) { console.log(JSON.stringify({ sessions: new Set(entries.map((entry) => entryFile.get(entry))).size, hours: Math.round(hours * 10) / 10, first: months[0] ?? null, last: months.at(-1) ?? null })); process.exit(0); }
865
+ 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 })); process.exit(0); }
769
866
  console.log(`${entries.length} day-stretches · ${hours.toFixed(1)} measured hours · ${months.join(", ")}`);
770
867
  if (DRY) {
771
868
  // One example in full: the shape is the argument for trusting it, and a summary hides it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worktrust",
3
- "version": "0.6.11",
3
+ "version": "0.6.13",
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": {
@@ -14,6 +14,8 @@
14
14
  "transcript-readers.mjs",
15
15
  "session-databases.mjs",
16
16
  "config-edits.mjs",
17
+ "preserve.mjs",
18
+ "preserve-summary.mjs",
17
19
  "README.md",
18
20
  "SECURITY.md",
19
21
  "LICENSE"
@@ -0,0 +1,39 @@
1
+ /**
2
+ * --summary: what a person or a team reads from the archive, counted locally after the chain checks out. Per month the
3
+ * hours, the tokens per model and the share of input served from the cache (where the tokens go, and so where they
4
+ * can be saved); and the PARALLEL WORK: per project and day the sessions' summed time against the wall-clock time they
5
+ * covered together, and the most open at once. A stretch without a project or a measured token stays out of that
6
+ * figure; nothing is estimated.
7
+ *
8
+ * Pure: the archive's verified lines in, the lines to print out. No file, no clock, no network.
9
+ */
10
+ export function summaryLines(lines) {
11
+ const out = [];
12
+ const months = new Map();
13
+ const month = (key) => months.get(key) ?? months.set(key, { seconds: 0, models: new Map(), input: 0, cached: 0, days: new Map() }).get(key);
14
+ for (const line of lines) {
15
+ const m = month(line.day.slice(0, 7));
16
+ m.seconds += line.seconds ?? 0;
17
+ if (typeof line.tokens_in === "number" || typeof line.tokens_out === "number") {
18
+ const name = line.model ?? "model not recorded";
19
+ m.models.set(name, (m.models.get(name) ?? 0) + (line.tokens_in ?? 0) + (line.tokens_out ?? 0));
20
+ }
21
+ if (typeof line.tokens_in === "number" && typeof line.tokens_cache_read === "number") { m.input += line.tokens_in + line.tokens_cache_read; m.cached += line.tokens_cache_read; }
22
+ if (line.project) { const key = `${line.day}|${line.project}`; (m.days.get(key) ?? m.days.set(key, []).get(key)).push([Date.parse(line.started_at), Date.parse(line.ended_at), line.seconds ?? 0]); }
23
+ }
24
+ const hours = (seconds) => (seconds / 3600).toFixed(1);
25
+ for (const [key, m] of [...months].sort()) {
26
+ out.push(` ${key} ${hours(m.seconds)} h measured`);
27
+ for (const [model, tokens] of [...m.models].sort((a, b) => b[1] - a[1])) out.push(` ${model.padEnd(28)} ${tokens.toLocaleString("en")} tokens`);
28
+ if (m.input > 0) out.push(` cache share of input ${Math.round((m.cached / m.input) * 100)}%`);
29
+ let parallelDays = 0, peak = 1, summed = 0, wall = 0;
30
+ for (const spans of m.days.values()) {
31
+ const edges = spans.flatMap(([from, to]) => [[from, 1], [to, -1]]).sort((a, b) => a[0] - b[0] || a[1] - b[1]);
32
+ let open = 0, top = 0, covered = 0, since = 0;
33
+ for (const [at, step] of edges) { if (open > 0) covered += at - since; open += step; since = at; top = Math.max(top, open); }
34
+ if (top > 1) { parallelDays += 1; peak = Math.max(peak, top); summed += spans.reduce((sum, span) => sum + span[2], 0); wall += covered / 1000; }
35
+ }
36
+ if (parallelDays > 0) out.push(` parallel sessions in one project on ${parallelDays} day${parallelDays === 1 ? "" : "s"}: up to ${peak} at once, ${hours(summed)} h of session time in ${hours(wall)} h of wall-clock time`);
37
+ }
38
+ return out;
39
+ }
package/preserve.mjs ADDED
@@ -0,0 +1,348 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * KEEP YOUR HISTORY: `npx worktrust@latest preserve` (2026-10-06). Local only: no account, no network, no key needed.
4
+ * The bare command walks every step on its own yes (see `guided` below); the flags do one step each, for scripts:
5
+ * --apply [--yes] keep the settings · --archive [dir] the metadata-only archive (default ~/AI-Evidence) · --verify [dir]
6
+ * its hashes, chain, roots and signatures · --summary [dir] hours, tokens per model, cache share, parallel sessions.
7
+ *
8
+ * WHY. The session hook measures from the AI apps' own session files, and several apps delete them: Claude Code after
9
+ * 30 days (`cleanupPeriodDays`, minimum 1, 0 is invalid), Gemini CLI after 30 (`general.sessionRetention`), Hermes Agent
10
+ * 90 days after a session ended (`sessions.auto_prune`). A deleted file is a day never measured again. Codex documents
11
+ * no deletion; Cursor, Copilot Chat, OpenCode, Goose and LM Studio document none, so nothing is claimed or changed for
12
+ * them (their docs, 2026-10-06).
13
+ *
14
+ * WHAT CHANGES, only on a typed y (Enter is No; --yes agrees in advance; no terminal and no --yes changes nothing):
15
+ * Claude Code's `cleanupPeriodDays` to 3650 when unset or lower, and Gemini CLI's `sessionRetention.enabled` to false;
16
+ * the file is copied beside itself first, every other key kept, a higher value never lowered, a file that is not plain
17
+ * JSON left alone. Hermes's YAML is never edited: the line is printed.
18
+ *
19
+ * WHAT THE ARCHIVE HOLDS. The hook's own day-stretches (`log-session.mjs --archive-lines`; nothing is parsed here) as
20
+ * allowlisted JSON lines in <dir>/YYYY/MM/stretches.jsonl: clocks, seconds and their layers, tokens, subagents, the
21
+ * model, the hook's own words, a hash of the project, the computer and profile as hashes. Never a prompt, an answer,
22
+ * code, a path, a branch or a title; a measurement not made is absent, never 0. Each line chains to the one before
23
+ * (`prev`, `hash`), appended and never rewritten; each day has proofs/<day>.json, its root, signed with the device key
24
+ * (~/.worktrust/key.json) when there is one. Today waits for the next run. Nothing leaves this computer.
25
+ */
26
+ import { execFileSync, spawnSync } from "node:child_process";
27
+ import { createHash, createPrivateKey, createPublicKey, sign, verify } from "node:crypto";
28
+ import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
29
+ import { homedir, platform } from "node:os";
30
+ import { dirname, join, relative } from "node:path";
31
+ import { createInterface } from "node:readline/promises";
32
+ import { fileURLToPath } from "node:url";
33
+ import { summaryLines } from "./preserve-summary.mjs";
34
+
35
+ const args = process.argv.slice(2).filter((arg) => arg !== "preserve");
36
+ const has = (name) => args.includes(`--${name}`);
37
+ /** `--archive [dir]`: the folder named after the flag, or the default. */
38
+ const folderAfter = (name) => { const at = args.indexOf(`--${name}`); const next = args[at + 1]; return next && !next.startsWith("--") ? next.replace(/^~(?=\/|$)/, homedir()) : join(homedir(), "AI-Evidence"); };
39
+ const HERE = dirname(fileURLToPath(import.meta.url));
40
+ const home = (...parts) => join(homedir(), ...parts);
41
+ const say = (line = "") => process.stdout.write(`${line}\n`);
42
+ const today = () => new Date().toISOString().slice(0, 10);
43
+ /** Which collector wrote a line: the CLI's version when worktrust.mjs starts this, else the package's beside it. */
44
+ const COLLECTOR = process.env.WORKTRUST_COLLECTOR || (() => { try { return `worktrust-cli/${JSON.parse(readFileSync(join(HERE, "package.json"), "utf8")).version}`; } catch { return "worktrust-cli/source"; } })();
45
+ const KEEP_DAYS = 3650;
46
+ let IDS = {}; // filled once per archive run (computerIds), never read at import
47
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
48
+ /** Keys sorted at every level: the same object always hashes the same, whatever order it was written in. */
49
+ const canonical = (value) => (Array.isArray(value) ? `[${value.map(canonical).join(",")}]` : value && typeof value === "object" ? `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}` : JSON.stringify(value));
50
+ const isDir = (path) => { try { return statSync(path).isDirectory(); } catch { return false; } };
51
+ /** A settings file: { value } when it is a JSON object or absent ({}), { value: null } when it exists and is not one. */
52
+ function readJson(file) {
53
+ if (!existsSync(file)) return { exists: false, value: {} };
54
+ try { const value = JSON.parse(readFileSync(file, "utf8")); return { exists: true, value: value && typeof value === "object" && !Array.isArray(value) ? value : null }; } catch { return { exists: true, value: null }; }
55
+ }
56
+ const appData = (app) => (platform() === "win32" && process.env.APPDATA ? join(process.env.APPDATA, app) : platform() === "darwin" ? home("Library", "Application Support", app) : join(process.env.XDG_CONFIG_HOME || home(".config"), app));
57
+
58
+ /** CLAUDE CODE: ~/.claude/projects/<project>/<session>.jsonl, deleted after `cleanupPeriodDays` (default 30). */
59
+ function claudeCode() {
60
+ const file = home(".claude", "settings.json");
61
+ const { value } = readJson(file);
62
+ const set = typeof value?.cleanupPeriodDays === "number" ? value.cleanupPeriodDays : null;
63
+ const days = set ?? 30;
64
+ return {
65
+ name: "Claude Code", found: isDir(home(".claude")), file,
66
+ keeps: value === null ? `${file} is not plain JSON; the default is 30 days` : set === null ? "30 days (the default; cleanupPeriodDays is not set)" : `${set} days (cleanupPeriodDays)`,
67
+ risk: days >= KEEP_DAYS ? "none: transcripts are kept ten years or more" : `transcripts not written for ${days} days are deleted in the background, and the hours in them with them`,
68
+ change: value === null ? { manual: [`add "cleanupPeriodDays": ${KEEP_DAYS} to ${file}`] } : days < KEEP_DAYS ? { says: `cleanupPeriodDays ${set ?? "unset"} → ${KEEP_DAYS} in ${file}`, next: (settings) => ({ ...settings, cleanupPeriodDays: KEEP_DAYS }) } : null,
69
+ };
70
+ }
71
+ /** GEMINI CLI: ~/.gemini/tmp/<project_hash>/chats, cleaned by `general.sessionRetention` (on by default, maxAge "30d"). */
72
+ function geminiCli() {
73
+ const file = home(".gemini", "settings.json");
74
+ const { exists, value } = readJson(file);
75
+ const retention = value?.general?.sessionRetention;
76
+ const on = retention?.enabled !== false;
77
+ const age = typeof retention?.maxAge === "string" ? retention.maxAge : "30d";
78
+ return {
79
+ name: "Gemini CLI", found: isDir(home(".gemini", "tmp")) || exists, file,
80
+ keeps: value === null ? `${file} is not plain JSON (comments?); the default is 30d` : !on ? "kept (general.sessionRetention.enabled: false)" : `${age}${retention?.maxAge ? " (general.sessionRetention.maxAge)" : " (the default; general.sessionRetention is not set)"}`,
81
+ risk: on ? `chats older than ${age} are deleted when Gemini CLI starts` : "none: the cleanup is off",
82
+ change: value === null ? { manual: [`set "general": { "sessionRetention": { "enabled": false } } in ${file}`] } : on ? { says: `general.sessionRetention.enabled → false in ${file}`, next: (settings) => ({ ...settings, general: { ...(settings.general ?? {}), sessionRetention: { ...(settings.general?.sessionRetention ?? {}), enabled: false } } }) } : null,
83
+ };
84
+ }
85
+ /** HERMES AGENT: ~/.hermes/state.db, pruned by `sessions.auto_prune` (default true) after `sessions.retention_days` (90). */
86
+ function hermesAgent() {
87
+ const base = platform() === "win32" && process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, "hermes") : home(".hermes");
88
+ const file = join(base, "config.yaml");
89
+ let rows = [];
90
+ try { rows = readFileSync(file, "utf8").split("\n"); } catch { /* none */ }
91
+ // Read only, never written: the `sessions:` block at the top level and its two keys, plainly written.
92
+ const start = rows.findIndex((row) => /^sessions:\s*(#.*)?$/.test(row));
93
+ const block = start < 0 ? [] : rows.slice(start + 1, (() => { const end = rows.findIndex((row, at) => at > start && /^\S/.test(row)); return end < 0 ? rows.length : end; })());
94
+ const read = (key) => block.map((row) => new RegExp(`^\\s+${key}:\\s*([^#\\s]+)`).exec(row)?.[1]).find(Boolean);
95
+ const prune = read("auto_prune") !== "false";
96
+ const days = Number(read("retention_days") ?? 90);
97
+ return {
98
+ name: "Hermes Agent", found: isDir(base), file,
99
+ keeps: prune ? `${days} days after a session ended${read("auto_prune") || read("retention_days") ? " (sessions in config.yaml)" : " (the default: sessions.auto_prune is on)"}` : "kept (sessions.auto_prune: false)",
100
+ risk: prune ? `ended sessions are pruned from state.db after ${days} days` : "none: pruning is off",
101
+ change: prune ? { manual: start < 0 ? [`add to ${file}:`, " sessions:", " auto_prune: false"] : [`in ${file}, under sessions:, set`, " auto_prune: false"] } : null,
102
+ };
103
+ }
104
+ const codexCli = () => ({ name: "Codex", found: isDir(home(".codex")), keeps: "no deletion documented (older rollouts are compressed)", risk: "none documented", change: null });
105
+ /** Retention not documented: said plainly, nothing changed. */
106
+ const undocumented = (name, places, note) => ({ name, found: places.some(isDir), keeps: "not documented", risk: note ?? "unknown: the app documents no retention", change: null });
107
+ const clients = () => [
108
+ claudeCode(), geminiCli(), hermesAgent(), codexCli(),
109
+ undocumented("Cursor", [appData("Cursor"), home(".cursor")]),
110
+ undocumented("Copilot Chat", ["Code", "Code - Insiders"].map((app) => join(appData(app), "User", "globalStorage", "github.copilot-chat")), "unknown: no retention documented; VS Code also syncs chat sessions to GitHub by default (chat.sessionSync.enabled)"),
111
+ undocumented("OpenCode", [join(process.env.XDG_DATA_HOME || home(".local", "share"), "opencode")]),
112
+ undocumented("Goose", [home(".local", "share", "goose"), home("Library", "Application Support", "Block", "goose"), home(".config", "goose")]),
113
+ undocumented("LM Studio", [home(".lmstudio"), home(".cache", "lm-studio")]),
114
+ ];
115
+
116
+ function report(list) {
117
+ say();
118
+ say(" WorkTrust · preserve: how long each AI app on this computer keeps its sessions");
119
+ say(" Nothing is sent anywhere, and nothing changes without your yes.");
120
+ for (const client of list) {
121
+ say();
122
+ if (!client.found) { say(` ${client.name.padEnd(14)} not on this computer`); continue; }
123
+ say(` ${client.name.padEnd(14)} found`);
124
+ say(` keeps ${client.keeps}`);
125
+ say(` risk ${client.risk}`);
126
+ if (client.change?.says) say(` --apply ${client.change.says}`);
127
+ else if (client.change?.manual) { say(` by hand ${client.change.manual[0]}`); for (const row of client.change.manual.slice(1)) say(` ${row}`); }
128
+ else say(" --apply nothing to change");
129
+ }
130
+ say();
131
+ }
132
+
133
+ async function ask(question) {
134
+ if (has("yes")) return true;
135
+ if (!process.stdin.isTTY) return null;
136
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
137
+ const answer = (await prompt.question(` ${question} [y/N] `)).trim().toLowerCase();
138
+ prompt.close();
139
+ return answer === "y" || answer === "yes";
140
+ }
141
+
142
+ /** A dated copy beside the file, never over an earlier one; then the new JSON, every other key as it was. */
143
+ function writeWithBackup(file, next) {
144
+ let backup = null;
145
+ if (existsSync(file)) {
146
+ backup = `${file}.worktrust-backup-${today()}`;
147
+ for (let n = 2; existsSync(backup); n += 1) backup = `${file}.worktrust-backup-${today()}-${n}`;
148
+ copyFileSync(file, backup);
149
+ }
150
+ mkdirSync(dirname(file), { recursive: true });
151
+ writeFileSync(file, `${JSON.stringify(next, null, 2)}\n`);
152
+ return backup;
153
+ }
154
+
155
+ async function apply(list, agreed = false) {
156
+ for (const client of list.filter((entry) => entry.found && entry.change)) {
157
+ if (client.change.manual) { say(` ${client.name}: not edited here; ${client.change.manual[0]}`); for (const row of client.change.manual.slice(1)) say(` ${row}`); continue; }
158
+ const yes = agreed || await ask(`${client.name}: ${client.change.says}?`);
159
+ if (yes === null) { say(` ${client.name}: not a terminal, nothing changed. Add --yes to agree in advance.`); continue; }
160
+ if (!yes) { say(` ${client.name}: left as it is.`); continue; }
161
+ const { value } = readJson(client.file);
162
+ if (value === null) { say(` ${client.name}: ${client.file} is no longer plain JSON; left as it is.`); continue; }
163
+ const backup = writeWithBackup(client.file, client.change.next(value));
164
+ say(` ✓ ${client.name}: ${client.change.says}${backup ? ` (the earlier file is kept as ${backup})` : ""}`);
165
+ }
166
+ say();
167
+ say(" Restart the apps whose settings changed. Nothing was sent anywhere.");
168
+ }
169
+
170
+ /** THE ALLOWLIST: what a line may carry, each value checked for its type. A key not named here never reaches the archive. */
171
+ const COUNTS = ["seconds", "tokens_in", "tokens_out", "tokens_cache_read", "tokens_cache_write", "model_seconds", "tool_seconds", "human_seconds", "idle_seconds", "agent_runs", "agent_seconds", "agent_peak"];
172
+ const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
173
+ /** The hook's own words for a stretch (work area, kind, how it was steered, the measuring bases), kept as the hook sends them. */
174
+ const WORDS = ["layer", "kind", "steered_from", "duration_basis", "token_basis", "turn_basis"];
175
+ const WORD = /^[a-z][A-Za-z]{1,31}$/;
176
+ /** WHICH COMPUTER, WHICH PROFILE: the one-way hashes `worktrust connect` derives (worktrust.mjs `computerIds`), the same text, so they match. */
177
+ function computerIds() {
178
+ let machine = null;
179
+ try {
180
+ const os = platform();
181
+ if (os === "darwin") machine = execFileSync("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], { encoding: "utf8", timeout: 3000 }).match(/"IOPlatformUUID" = "([0-9A-Fa-f-]{36})"/)?.[1] ?? null;
182
+ else if (os === "win32") machine = execFileSync("reg", ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"], { encoding: "utf8", timeout: 3000 }).match(/MachineGuid\s+REG_SZ\s+([0-9A-Fa-f-]{36})/)?.[1] ?? null;
183
+ else for (const file of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) if (!machine && existsSync(file)) { const id = readFileSync(file, "utf8").trim(); if (/^[0-9a-f]{32}$/.test(id)) machine = id; }
184
+ } catch { /* unreadable: no identity */ }
185
+ if (process.env.WORKTRUST_MACHINE_ID) machine = process.env.WORKTRUST_MACHINE_ID; // tests only
186
+ if (!machine) return {};
187
+ const hash = (text) => createHash("sha256").update(text).digest("base64url");
188
+ return { device_id: hash(`worktrust-machine|${machine.toLowerCase()}`), profile_id: hash(`worktrust-profile|${machine.toLowerCase()}|${homedir()}`) };
189
+ }
190
+ function archiveLine(entry) {
191
+ if (typeof entry?.client !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(entry.client) || !ISO.test(entry.started_at ?? "") || !ISO.test(entry.at ?? "") || !/^[A-Za-z0-9_-]{16,128}$/.test(entry.stretch_ref ?? "")) return null;
192
+ const line = { client: entry.client, day: entry.started_at.slice(0, 10), started_at: entry.started_at, ended_at: entry.at };
193
+ for (const key of COUNTS) if (typeof entry[key] === "number" && Number.isFinite(entry[key]) && entry[key] >= 0) line[key] = entry[key];
194
+ if (typeof entry.model === "string" && /^[A-Za-z0-9][A-Za-z0-9._:/ -]{0,63}$/.test(entry.model)) line.model = entry.model;
195
+ for (const key of WORDS) if (typeof entry[key] === "string" && WORD.test(entry[key])) line[key] = entry[key];
196
+ if (Array.isArray(entry.layers) && entry.layers.length > 1 && entry.layers.every((word) => typeof word === "string" && WORD.test(word))) line.layers = entry.layers.slice(0, 8);
197
+ if (typeof entry.exchanges === "number" && Number.isInteger(entry.exchanges) && entry.exchanges > 0) line.exchanges = entry.exchanges;
198
+ // Commit hashes the stretch made, never a message: what ties AI time to delivered work in the repository.
199
+ if (Array.isArray(entry.commits) && entry.commits.length > 0 && entry.commits.every((sha) => typeof sha === "string" && /^[0-9a-f]{7,40}$/.test(sha))) line.commits = entry.commits.slice(0, 50);
200
+ // The project as a one-way hash of its name (owner/name, else the folder's own name): which days belong together, never which project.
201
+ const name = entry.repo ?? entry.folder;
202
+ if (typeof name === "string" && name) line.project = sha256(`worktrust-project|${name}`);
203
+ return { ...line, stretch_ref: entry.stretch_ref, ...IDS, collector_version: COLLECTOR };
204
+ }
205
+
206
+ /** The device key, when this computer is coupled: its private half signs, its public half goes into the proof. */
207
+ function deviceKey() {
208
+ try {
209
+ const key = JSON.parse(Buffer.from(readFileSync(home(".worktrust", "key.json"), "utf8"), "base64").toString("utf8"));
210
+ const privateKey = createPrivateKey(key.device);
211
+ return { privateKey, publicX: createPublicKey(privateKey).export({ format: "jwk" }).x };
212
+ } catch { return null; }
213
+ }
214
+
215
+ /** Every line of an archive, where it lives, parsed or not. */
216
+ function readArchive(dir) {
217
+ const rows = [];
218
+ for (const year of readdirSafe(dir).filter((name) => /^\d{4}$/.test(name)).sort()) for (const month of readdirSafe(join(dir, year)).filter((name) => /^\d{2}$/.test(name)).sort()) {
219
+ const file = join(dir, year, month, "stretches.jsonl");
220
+ if (!existsSync(file)) continue;
221
+ readFileSync(file, "utf8").split("\n").forEach((text, at) => { if (text.trim()) { let line = null; try { line = JSON.parse(text); } catch { /* reported by check */ } rows.push({ line, where: `${year}/${month}/stretches.jsonl line ${at + 1}`, month: `${year}-${month}` }); } });
222
+ }
223
+ return rows;
224
+ }
225
+ function readdirSafe(dir) { try { return readdirSync(dir); } catch { return []; } }
226
+ const rootOf = (hashes) => sha256(hashes.join("\n"));
227
+ const proofBody = (date, lines, key) => ({ date, count: lines.length, root: rootOf(lines.map((line) => line.hash)), collector_version: COLLECTOR, ...(key ? { public_key: key.publicX } : {}) });
228
+
229
+ /** THE CHECK, shared by --verify and by --archive before it appends: the first break, or the archive's facts. */
230
+ function check(dir) {
231
+ const rows = readArchive(dir);
232
+ const broken = (where, why) => ({ broken: `${where}: ${why}` });
233
+ for (const { line, where, month } of rows) {
234
+ if (!line || typeof line !== "object") return broken(where, "not a JSON line");
235
+ const { hash, ...rest } = line;
236
+ if (hash !== sha256(canonical(rest))) return broken(where, "its hash does not match its content");
237
+ if (line.day?.slice(0, 7) !== month) return broken(where, `a line of ${line.day} in the month folder ${month}`);
238
+ }
239
+ const lines = rows.map((row) => row.line).sort((a, b) => a.seq - b.seq);
240
+ for (const [at, line] of lines.entries()) {
241
+ const where = rows.find((row) => row.line === line).where;
242
+ if (line.seq !== at + 1) return broken(where, `seq ${line.seq} where ${at + 1} was expected (a line is missing or doubled)`);
243
+ if (line.prev !== (at === 0 ? null : lines[at - 1].hash)) return broken(where, "prev does not name the line before it: the chain is broken");
244
+ }
245
+ const manifest = readJson(join(dir, "manifest.json")).value;
246
+ if (rows.length > 0 && (!manifest || manifest.count !== lines.length || manifest.head !== lines.at(-1).hash)) return broken("manifest.json", "its count or head does not match the lines (lines were removed or added outside preserve)");
247
+ const days = new Map();
248
+ for (const line of lines) days.set(line.day, [...(days.get(line.day) ?? []), line]);
249
+ const mine = deviceKey()?.publicX ?? null;
250
+ let signed = 0, ours = 0;
251
+ for (const name of readdirSafe(join(dir, "proofs"))) if (!days.has(name.replace(/\.json$/, ""))) return broken(`proofs/${name}`, "a proof for a day with no lines");
252
+ for (const [day, ofDay] of days) {
253
+ const where = `proofs/${day}.json`;
254
+ const proof = readJson(join(dir, "proofs", `${day}.json`)).value;
255
+ if (!proof || proof.date !== day) return broken(where, "missing or unreadable");
256
+ if (proof.count !== ofDay.length || proof.root !== rootOf(ofDay.map((line) => line.hash))) return broken(where, "the day's root does not match its lines");
257
+ if (proof.signature === null) continue;
258
+ const { signature, ...body } = proof;
259
+ let valid = false;
260
+ try { valid = verify(null, Buffer.from(canonical(body)), createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: body.public_key }, format: "jwk" }), Buffer.from(signature, "base64url")); } catch { valid = false; }
261
+ if (!valid) return broken(where, "the signature does not verify with the key named in it");
262
+ signed += 1;
263
+ if (body.public_key === mine) ours += 1;
264
+ }
265
+ return { lines, days, signed, ours, manifest };
266
+ }
267
+
268
+ function archive(dir) {
269
+ IDS = computerIds();
270
+ const existing = existsSync(join(dir, "manifest.json")) || readArchive(dir).length > 0 ? check(dir) : { lines: [], manifest: null };
271
+ if (existing.broken) { say(` The archive in ${dir} does not verify, so nothing was added: ${existing.broken}`); process.exit(1); }
272
+ const hook = spawnSync(process.execPath, [join(HERE, "log-session.mjs"), "--archive-lines"], { encoding: "utf8", maxBuffer: 1 << 29, env: { ...process.env, WORKTRUST_MCP_URL: "", WORKTRUST_MCP_TOKEN: "", WORKTRUST_HOOK_INPUT: "" } });
273
+ if (hook.status !== 0) { say(` The session hook could not read this computer's sessions: ${(hook.stderr || "").trim().split("\n").pop()}`); process.exit(1); }
274
+ const known = new Set(existing.lines.map((line) => line.stretch_ref));
275
+ const fresh = [];
276
+ let waiting = 0;
277
+ for (const text of hook.stdout.split("\n")) {
278
+ let entry; try { entry = JSON.parse(text); } catch { continue; }
279
+ const line = archiveLine(entry);
280
+ if (!line || known.has(line.stretch_ref)) continue;
281
+ if (line.day >= today()) { waiting += 1; continue; }
282
+ known.add(line.stretch_ref);
283
+ fresh.push(line);
284
+ }
285
+ fresh.sort((a, b) => a.started_at.localeCompare(b.started_at) || a.client.localeCompare(b.client) || a.stretch_ref.localeCompare(b.stretch_ref));
286
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
287
+ let prev = existing.lines.at(-1)?.hash ?? null, seq = existing.lines.length;
288
+ const all = [...existing.lines];
289
+ for (const line of fresh) {
290
+ const chained = { ...line, seq: (seq += 1), prev };
291
+ const hashed = { ...chained, hash: sha256(canonical(chained)) };
292
+ mkdirSync(join(dir, line.day.slice(0, 4), line.day.slice(5, 7)), { recursive: true });
293
+ appendFileSync(join(dir, line.day.slice(0, 4), line.day.slice(5, 7), "stretches.jsonl"), `${JSON.stringify(hashed)}\n`);
294
+ all.push(hashed);
295
+ prev = hashed.hash;
296
+ }
297
+ // A day that gained lines gets its root again over all of its lines; the lines themselves are never rewritten.
298
+ const key = deviceKey();
299
+ mkdirSync(join(dir, "proofs"), { recursive: true });
300
+ for (const day of new Set(fresh.map((line) => line.day))) {
301
+ const body = proofBody(day, all.filter((line) => line.day === day), key);
302
+ writeFileSync(join(dir, "proofs", `${day}.json`), `${JSON.stringify({ ...body, signature: key ? sign(null, Buffer.from(canonical(body)), key.privateKey).toString("base64url") : null }, null, 1)}\n`);
303
+ }
304
+ if (fresh.length > 0 || !existing.manifest) writeFileSync(join(dir, "manifest.json"), `${JSON.stringify({ format: "worktrust-ai-evidence/1", collector_version: COLLECTOR, created: existing.manifest?.created ?? new Date().toISOString(), updated: new Date().toISOString(), clients: [...new Set(all.map((line) => line.client))].sort(), ...IDS, count: all.length, head: prev }, null, 1)}\n`);
305
+ say();
306
+ say(` ✓ ${fresh.length} new day-stretch${fresh.length === 1 ? "" : "es"} archived in ${dir} (${all.length} in all)${key ? ", each day's root signed with this computer's device key" : ", unsigned (no device key on this computer)"}.`);
307
+ if (waiting > 0) say(` ${waiting} of today wait for tomorrow's run: a day is archived once it is over.`);
308
+ say(" Metadata only: clocks, counts, model names, a hash of each project's name. Nothing was sent.");
309
+ }
310
+
311
+ function verifyArchive(dir) {
312
+ const result = check(dir);
313
+ if (result.broken) { say(` BROKEN ${result.broken}`); process.exit(1); }
314
+ if (result.lines.length === 0) { say(` Nothing to verify in ${dir}.`); return; }
315
+ const signedBy = result.signed === 0 ? "no day signed" : result.ours === result.signed ? `${result.signed} signed by this computer's key` : `${result.signed} signed (${result.ours} by this computer's key, ${result.signed - result.ours} by another key named in them)`;
316
+ say(` OK ${result.lines.length} stretches · ${result.days.size} days · the chain is whole · every day's root matches · ${signedBy}`);
317
+ }
318
+
319
+ /** --summary: what a person or a team reads from the archive, counted locally (preserve-summary.mjs) once the chain checks out. */
320
+ function summary(dir) {
321
+ const result = check(dir);
322
+ if (result.broken) { say(` BROKEN ${result.broken}`); process.exit(1); }
323
+ for (const line of summaryLines(result.lines)) say(line);
324
+ if (result.lines.length === 0) say(` Nothing archived in ${dir} yet. Run: npx worktrust@latest preserve --archive`);
325
+ }
326
+
327
+ /** THE ONE COMMAND (owner, 2026-10-07: typed from a video): the report; the settings on a yes (--apply); an archive on a yes
328
+ * (--archive; one already there is added to); then its check and summary. Enter is No; no terminal changes nothing. */
329
+ async function guided() {
330
+ const list = clients(), dir = join(homedir(), "AI-Evidence");
331
+ report(list);
332
+ if (list.some((client) => client.found && client.change?.says)) {
333
+ const keep = await ask("Keep these settings?");
334
+ if (keep) await apply(list, true);
335
+ else say(keep === null ? " Not a terminal: no setting changed. Add --yes to agree in advance." : " Settings left as they are.");
336
+ }
337
+ const kept = existsSync(join(dir, "manifest.json"));
338
+ const write = kept ? (has("yes") || process.stdin.isTTY ? true : null) : await ask(`Keep a local archive in ${dir}?`);
339
+ if (write) archive(dir);
340
+ else if (!kept) say(write === null ? " Not a terminal: no archive written. Add --yes to agree in advance." : " No archive written.");
341
+ if (existsSync(join(dir, "manifest.json"))) { say(); verifyArchive(dir); summary(dir); }
342
+ }
343
+
344
+ if (has("summary")) summary(folderAfter("summary"));
345
+ else if (has("verify")) verifyArchive(folderAfter("verify"));
346
+ else if (has("archive")) archive(folderAfter("archive"));
347
+ else if (has("apply")) { const list = clients(); report(list); await apply(list); }
348
+ else await guided();
@@ -371,7 +371,7 @@ export function cursorLines(pseudo) {
371
371
  * model finished (`modelState.completedAt`), the model it resolved to and the prompt and output tokens. The messages,
372
372
  * the responses and the tool calls' text are parsed with the file and never kept. Agent mode runs a request's tools
373
373
  * between those two clocks without stamping each step, so under the same rule as every other client that span counts
374
- * up to the idle cap, as one gap.
374
+ * up to the idle cap, as one gap, in `seconds`; in the layers it is the agent's run, whole (the assistant line says `agentic`).
375
375
  */
376
376
  export function copilotHomes(given) { return given ? [given.replace(/^~(?=\/|$)/, homedir())] : [...userDirs("Code"), ...userDirs("Code - Insiders")]; }
377
377
  export function* copilotSessions(homes) {
@@ -420,7 +420,8 @@ export function copilotLines(pseudo) {
420
420
  if (!done || done < asked) continue;
421
421
  const meta = request?.result?.metadata ?? {};
422
422
  const usage = Number(meta.promptTokens) || Number(meta.outputTokens) ? { usage: { input_tokens: Number(meta.promptTokens) || 0, output_tokens: Number(meta.outputTokens) || 0 } } : {};
423
- lines.push({ type: "assistant", timestamp: new Date(done).toISOString(), message: { model: cleanModel(String(meta.resolvedModel ?? request?.modelId ?? "").replace(/^copilot\//, "")), ...usage, content: [] } });
423
+ // `agentic` (owner, 2026-10-06): the request's own run, asked → completed, is the agent's time; the hook files that gap as tool time, uncapped.
424
+ lines.push({ type: "assistant", timestamp: new Date(done).toISOString(), agentic: true, message: { model: cleanModel(String(meta.resolvedModel ?? request?.modelId ?? "").replace(/^copilot\//, "")), ...usage, content: [] } });
424
425
  }
425
426
  return lines;
426
427
  }
package/worktrust.mjs CHANGED
@@ -7,11 +7,15 @@
7
7
  * npx worktrust --dry-run show the plan only; nothing is written, nothing is sent
8
8
  * npx worktrust --yes no question (for scripts); the computer is still approved in the app
9
9
  * npx worktrust --device a computer without a browser (SSH): type a code on another device
10
- * npx worktrust connect couple again, also on a computer already coupled
11
- * npx worktrust update this computer onto the newest CLI, same key, no new pairing
12
- * npx worktrust history send this computer's earlier sessions as history (asks first)
13
- * npx worktrust disconnect show what comes out, ask, take it out again
14
- * npx worktrust status what is coupled here
10
+ * npx worktrust@latest connect couple again, also on a computer already coupled
11
+ * npx worktrust@latest update this computer onto the newest CLI, same key, no new pairing
12
+ * npx worktrust@latest history send this computer's earlier sessions as history (asks first)
13
+ * npx worktrust@latest disconnect show what comes out, ask, take it out again
14
+ * npx worktrust@latest status what is coupled here
15
+ * npx worktrust@latest preserve how long each AI app here keeps its sessions (--apply keeps them,
16
+ * --archive [dir] a local metadata-only record, --verify [dir] checks it,
17
+ * --summary [dir] counts hours, tokens and parallel sessions from it);
18
+ * local only: no account, no key, no network (preserve.mjs)
15
19
  *
16
20
  * WHAT RUNS. From the npm package, everything that runs is in the package: this file,
17
21
  * `setup-mcp.mjs` (gives every AI app here the WorkTrust door), `log-session.mjs` (the session hook)
@@ -65,7 +69,7 @@ const command = args.find((arg, at) => !arg.startsWith("--") && !(at > 0 && VALU
65
69
  const flag = (name) => { const at = args.indexOf(`--${name}`); return at >= 0 ? args[at + 1] : undefined; };
66
70
  const has = (name) => args.includes(`--${name}`);
67
71
  /** This CLI's version, said to the door so the app can tell which computer runs an old one (check-cli-package holds it equal to package.json). */
68
- const CLI_VERSION = "0.6.11";
72
+ const CLI_VERSION = "0.6.13";
69
73
  const ORIGIN = (flag("origin") ?? process.env.WORKTRUST_ORIGIN ?? "https://app.worktrust.io").replace(/\/$/, "");
70
74
  const MCP = flag("url") ?? process.env.WORKTRUST_MCP_URL ?? `${ORIGIN}/api/mcp`;
71
75
  const HOME_DIR = join(homedir(), ".worktrust");
@@ -208,7 +212,7 @@ async function pairByBrowser(host, extra) {
208
212
  const exchange = await post("/api/cli/token", { device_code: secret, code, code_verifier: verifier }).catch(() => null);
209
213
  if (!exchange || exchange.status !== 200 || !exchange.json.token) fail(`the approval could not be exchanged (${exchange?.json?.error ?? "no answer"}). Run \`worktrust connect\` again.`);
210
214
  say(` ✓ Approved as "${exchange.json.label}"${exchange.json.account ? ` for the WorkTrust account ${exchange.json.account}` : ""}`);
211
- if (exchange.json.account) say(" Not your account? Run `npx worktrust disconnect` now and revoke it in WorkTrust.");
215
+ if (exchange.json.account) say(" Not your account? Run `npx worktrust@latest disconnect` now and revoke it in WorkTrust.");
212
216
  if (extraBound && exchange.json.bound === false) say(" ! WorkTrust did not bind the key to this computer; it works like a key made by hand.");
213
217
  return paired(exchange.json);
214
218
  }
@@ -237,7 +241,7 @@ async function pairByCode(host, extra) {
237
241
  if (!answer) continue;
238
242
  if (answer.status === 200 && answer.json.token) {
239
243
  say(` ✓ Approved as "${answer.json.label}"${answer.json.account ? ` for the WorkTrust account ${answer.json.account}` : ""}`);
240
- if (answer.json.account) say(" Not your account? Run `npx worktrust disconnect` now and revoke it in WorkTrust.");
244
+ if (answer.json.account) say(" Not your account? Run `npx worktrust@latest disconnect` now and revoke it in WorkTrust.");
241
245
  return paired(answer.json);
242
246
  }
243
247
  if (answer.json.error === "slow_down") wait += 2000;
@@ -354,7 +358,7 @@ function withLock(work) {
354
358
  /**
355
359
  * A KEY FILE ON OTHER HARDWARE (0.6.10, owner 2026-10-05: "a backup restored onto a new device must not upload to the
356
360
  * passport unseen"). The key file remembers the profile it was made on; on any other computer it sends nothing, and
357
- * WorkTrust is told once (`/api/cli/moved`), so the key is ended and the owner sees it. `npx worktrust connect` couples this one.
361
+ * WorkTrust is told once (`/api/cli/moved`), so the key is ended and the owner sees it. `npx worktrust@latest connect` couples this one.
358
362
  */
359
363
  async function copiedHere(key) {
360
364
  const here = computerIds().profile_id;
@@ -364,7 +368,7 @@ async function copiedHere(key) {
364
368
  const url = `${new URL(key.url).origin}/api/cli/moved`, body = JSON.stringify(computerIds());
365
369
  await fetch(url, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${key.token}`, ...proof(key.device, url, body) }, body });
366
370
  } catch { /* offline: it is told next time */ }
367
- process.stderr.write("WorkTrust: this key was made on another computer (copied from a backup?). Nothing is sent from here; run `npx worktrust connect` on this computer.\n");
371
+ process.stderr.write("WorkTrust: this key was made on another computer (copied from a backup?). Nothing is sent from here; run `npx worktrust@latest connect` on this computer.\n");
368
372
  return true;
369
373
  }
370
374
 
@@ -431,7 +435,7 @@ async function bridge() {
431
435
  let message;
432
436
  try { message = JSON.parse(line); } catch { continue; }
433
437
  if (message.method === "initialize" && message.params?.clientInfo?.name) agent = `${message.params.clientInfo.name}/${message.params.clientInfo.version ?? "0"} (worktrust-bridge) worktrust-cli/${CLI_VERSION}`;
434
- if (!key) { if (message.id !== undefined) answer(message.id, "WorkTrust is not connected on this computer: run `npx worktrust connect`."); continue; }
438
+ if (!key) { if (message.id !== undefined) answer(message.id, "WorkTrust is not connected on this computer: run `npx worktrust@latest connect`."); continue; }
435
439
  try {
436
440
  const response = await fetch(key.url, { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${key.token}`, "user-agent": agent, ...(session ? { "mcp-session-id": session } : {}), ...proof(key.device, key.url, line) }, body: line });
437
441
  session = response.headers.get("mcp-session-id") ?? session;
@@ -444,7 +448,7 @@ async function bridge() {
444
448
  const again = await fetch(key.url, { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${key.token}`, "user-agent": agent, ...(session ? { "mcp-session-id": session } : {}), ...proof(key.device, key.url, line) }, body: line });
445
449
  if (again.status !== 401) { const text = await again.text(); const parts = (again.headers.get("content-type") ?? "").includes("event-stream") ? text.split("\n").filter((part) => part.startsWith("data:")).map((part) => part.slice(5).trim()) : [text.trim()]; for (const part of parts.filter(Boolean)) process.stdout.write(`${part}\n`); continue; }
446
450
  }
447
- answer(message.id, "WorkTrust did not accept this computer's key: run `npx worktrust connect` again.");
451
+ answer(message.id, "WorkTrust did not accept this computer's key: run `npx worktrust@latest connect` again.");
448
452
  continue;
449
453
  }
450
454
  const text = await response.text();
@@ -502,7 +506,7 @@ async function sessionEnd() {
502
506
  }
503
507
 
504
508
  /**
505
- * `npx worktrust update` (owner, 2026-10-04): THIS COMPUTER ONTO THE NEWEST CLI, WITHOUT PAIRING IT
509
+ * `npx worktrust@latest update` (owner, 2026-10-04): THIS COMPUTER ONTO THE NEWEST CLI, WITHOUT PAIRING IT
506
510
  * AGAIN. `connect` mints a new key and so a new device row to merge; an update keeps the key and the
507
511
  * row. It writes what `connect` writes after its pairing: the bridge copy in ~/.worktrust, every
508
512
  * WorkTrust entry in the AI apps taken out (a hand-made one with a key in it too) and the bridge put
@@ -611,12 +615,15 @@ async function connect() {
611
615
  if (switched) { say(); say(" Earlier work on this computer is not offered: it was coupled to another account before. Only work from now on goes to this account."); }
612
616
  else await offerHistory(paths, token, device ? device.privateKey.export({ format: "pem", type: "pkcs8" }) : null);
613
617
  say();
618
+ // ONE LINE, NO CHANGE (2026-10-06): Claude Code deletes what the hook measures from; `preserve --apply` keeps it, on a yes.
619
+ const keeps = claudeKeepsDays();
620
+ if (keeps !== null && keeps < 90) say(` Claude Code deletes sessions after ${keeps} days. Keep them: npx worktrust@latest preserve --apply`);
614
621
  say(" Done. Quit your AI apps completely and open them again. This computer appears in WorkTrust");
615
622
  say(" under Sources → CLI once one of them has spoken.");
616
623
  }
617
624
 
618
625
  /**
619
- * EARLIER WORK IS SENT ONLY ON A YES (owner, 2026-10-03). After coupling, or as `npx worktrust history`:
626
+ * EARLIER WORK IS SENT ONLY ON A YES (owner, 2026-10-03). After coupling, or as `npx worktrust@latest history`:
620
627
  * what this computer holds is counted here and shown, and nothing leaves before the person types y.
621
628
  * Enter, --yes and a closed terminal all mean No; `--history` says yes in advance. What goes is hours
622
629
  * and tokens per day, filed as history (never verified hours, never text).
@@ -630,9 +637,9 @@ async function offerHistory(paths, token, devicePem, url = MCP) {
630
637
  if (!found?.sessions) { say(" No earlier Claude Code, Codex or Antigravity sessions on this computer to send."); return; }
631
638
  say(` This computer holds ${found.sessions} earlier Claude Code / Codex / Antigravity sessions · ${found.hours} measured hours · ${found.first} … ${found.last}.`);
632
639
  if (has("dry-run")) { say(" Dry run: nothing was sent."); return; }
633
- 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 history"); 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; }
634
641
  const sent = await run(paths["log-session.mjs"], ["--history"], token, true, env);
635
- 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 history again: a line already received is kept once.");
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.");
636
643
  }
637
644
 
638
645
  async function history() {
@@ -789,10 +796,10 @@ function status() {
789
796
  for (const project of Object.values(config.projects ?? {})) Object.assign(servers, project.mcpServers ?? {});
790
797
  } catch { /* no Claude Code here */ }
791
798
  const door = Object.entries(servers).find(([name, server]) => /worktrust/i.test(`${name} ${server?.url ?? ""} ${(server?.args ?? []).join(" ")}`));
792
- say(door ? ` Claude Code: coupled (${door[1].url ?? "through the local bridge"})` : " Claude Code: not coupled. Run `npx worktrust connect`.");
799
+ say(door ? ` Claude Code: coupled (${door[1].url ?? "through the local bridge"})` : " Claude Code: not coupled. Run `npx worktrust@latest connect`.");
793
800
  const key = keyStore.load();
794
801
  say(key ? ` Key: in ${KEY_PLACE}, ${key.device ? "bound to this computer, " : ""}last renewed ${String(key.renewedAt ?? "").slice(0, 10) || "never"}` : " Key: not on this computer (or written into the apps' settings with --direct)");
795
- if (key) say(key.account ? ` Account: ${key.account}${key.coupledAt ? `, coupled ${String(key.coupledAt).slice(0, 10)}` : ""}` : " Account: not recorded (coupled by an older version; `npx worktrust connect` records it)");
802
+ if (key) say(key.account ? ` Account: ${key.account}${key.coupledAt ? `, coupled ${String(key.coupledAt).slice(0, 10)}` : ""}` : " Account: not recorded (coupled by an older version; `npx worktrust@latest connect` records it)");
796
803
  let hook = false;
797
804
  try { hook = /log-session|worktrust\.mjs\\?" hook/.test(readFileSync(join(homedir(), ".claude", "settings.json"), "utf8")); } catch { /* none */ }
798
805
  say(hook ? " Session hook: installed" : " Session hook: not installed");
@@ -802,6 +809,23 @@ function status() {
802
809
  say(existsSync(join(HOME_DIR, "pinned")) ? " Counter: from the package, pinned" : existsSync(join(HOME_DIR, "count-behaviour.mjs")) ? " Counter: follows the site" : " Counter: not installed");
803
810
  }
804
811
 
812
+ /** Claude Code's `cleanupPeriodDays` (its default 30), or null without Claude Code here. Read only: preserve.mjs changes it, on a yes. */
813
+ function claudeKeepsDays() {
814
+ if (!existsSync(join(homedir(), ".claude"))) return null;
815
+ try { const days = JSON.parse(readFileSync(join(homedir(), ".claude", "settings.json"), "utf8")).cleanupPeriodDays; return typeof days === "number" ? days : 30; } catch { return 30; }
816
+ }
817
+
818
+ /**
819
+ * KEEP YOUR HISTORY (`npx worktrust@latest preserve`, 2026-10-06): preserve.mjs beside this file, run as it is. Local only: no
820
+ * key, no account, no connection. From a single downloaded file it is not offered, since fetching it would be one.
821
+ */
822
+ async function preserve() {
823
+ const script = join(HERE, "preserve.mjs");
824
+ if (!existsSync(script)) fail("preserve runs from the package: npx worktrust@latest preserve");
825
+ const child = spawn(process.execPath, [script, ...args.filter((arg) => arg !== "preserve")], { stdio: "inherit", env: { ...process.env, WORKTRUST_COLLECTOR: `worktrust-cli/${CLI_VERSION}` } });
826
+ process.exitCode = await new Promise((resolve) => child.on("exit", (code) => resolve(code ?? 1)));
827
+ }
828
+
805
829
  function help() {
806
830
  say("WorkTrust: couple this computer to your record. Metadata only.");
807
831
  say();
@@ -818,12 +842,17 @@ function help() {
818
842
  say(" worktrust history send this computer's earlier sessions as history (asks first)");
819
843
  say(" worktrust disconnect take WorkTrust out of every AI app here (asks first)");
820
844
  say(" worktrust status what is coupled here");
845
+ say(" worktrust preserve keep your AI history: the report, then the settings and a local archive, each on your yes");
846
+ say(" --apply keep them: asks per change, backs the file up first (--yes: no question)");
847
+ say(" --archive [dir] a local, metadata-only, hash-chained record of the measured days (~/AI-Evidence)");
848
+ say(" --verify [dir] check that record: every hash, the chain, each day's root and signature");
849
+ say(" --summary [dir] hours, tokens per model, cache share and parallel sessions, counted from that record");
821
850
  }
822
851
 
823
852
  // THE BARE COMMAND COUPLES (owner, 2026-10-03: "zo kort mogelijk"). `npx worktrust` on a computer
824
853
  // without a key here shows the plan and asks, exactly as `connect` does; with one it says what is
825
854
  // coupled, so running it twice never re-pairs by surprise.
826
- if (command === "default") { if (keyStore.load()) { status(); say(); say(" This computer is coupled. `npx worktrust connect` couples it again; `npx worktrust disconnect` takes it out."); } else await connect(); }
855
+ if (command === "default") { if (keyStore.load()) { status(); say(); say(" This computer is coupled. `npx worktrust@latest connect` couples it again; `npx worktrust@latest disconnect` takes it out."); } else await connect(); }
827
856
  else if (command === "mcp") await bridge();
828
857
  else if (command === "hook") await hook();
829
858
  else if (command === "connect") await connect();
@@ -840,4 +869,5 @@ else if (command === "codex") await offerCodex();
840
869
  else if (command === "antigravity") await offerAntigravity();
841
870
  else if (command === "disconnect") await disconnect();
842
871
  else if (command === "status") status();
872
+ else if (command === "preserve") await preserve();
843
873
  else help();