worktrust 0.6.12 → 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 +109 -8
- package/log-session.mjs +7 -7
- package/package.json +3 -1
- package/preserve-summary.mjs +39 -0
- package/preserve.mjs +348 -0
- package/worktrust.mjs +49 -19
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
|
|
@@ -72,13 +78,107 @@ curl -fsSO https://app.worktrust.io/counter/worktrust.mjs && node worktrust.mjs
|
|
|
72
78
|
open, starts the session hook in the background, at most once an hour.
|
|
73
79
|
|
|
74
80
|
```
|
|
75
|
-
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)
|
|
76
82
|
npx worktrust codex let Codex wake the session hook (asks first)
|
|
77
83
|
npx worktrust antigravity let Antigravity wake the session hook (asks first)
|
|
78
|
-
npx worktrust status what is coupled here
|
|
79
|
-
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
|
|
80
116
|
```
|
|
81
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
|
+
|
|
82
182
|
## Where the key lives, and why it only works here
|
|
83
183
|
|
|
84
184
|
The key is kept in **one file only you can read**: `~/.worktrust/key.json` (mode 600, in a folder of
|
|
@@ -110,9 +210,10 @@ which would delete every password on the computer, and many people rightly keep
|
|
|
110
210
|
|
|
111
211
|
## Why you can check it
|
|
112
212
|
|
|
113
|
-
- **No dependencies and no install scripts.**
|
|
213
|
+
- **No dependencies and no install scripts.** Plain files, readable
|
|
114
214
|
in an afternoon: `worktrust.mjs` (this command), `setup-mcp.mjs` (writes the MCP entries),
|
|
115
|
-
`log-session.mjs` (the session hook)
|
|
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.
|
|
116
217
|
- **Nothing is downloaded at run time.** Everything that runs is in the package. The counter is
|
|
117
218
|
pinned: it is not replaced from the network; a new one comes with a new version of this package.
|
|
118
219
|
- **Provenance.** Every version is built and published from a public repository by GitHub Actions
|
|
@@ -125,7 +226,7 @@ which would delete every password on the computer, and many people rightly keep
|
|
|
125
226
|
|
|
126
227
|
## A computer that is already coupled, or shared
|
|
127
228
|
|
|
128
|
-
- `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
|
|
129
230
|
asks `Couple it again? [y/N]`. Enter and `--yes` mean No; `--replace` says yes in advance.
|
|
130
231
|
- The browser page that approves it shows, large, which WorkTrust account is signed in, with
|
|
131
232
|
**Use another account**: the computer goes to whoever approves it there.
|
|
@@ -134,13 +235,13 @@ which would delete every password on the computer, and many people rightly keep
|
|
|
134
235
|
- When the new account is a different one, the old account sees a notice that its computer moved,
|
|
135
236
|
with the computer's name and the moment, never the other account; no earlier work on this
|
|
136
237
|
computer is offered to the new account, only work from the moment of coupling.
|
|
137
|
-
- `npx worktrust status` shows the account this computer sends to.
|
|
238
|
+
- `npx worktrust@latest status` shows the account this computer sends to.
|
|
138
239
|
- Different people on one computer with their own OS user each couple their own: everything lives
|
|
139
240
|
in that user's home folder, and the accounts never meet.
|
|
140
241
|
|
|
141
242
|
## Undo
|
|
142
243
|
|
|
143
|
-
`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
|
|
144
245
|
Antigravity's `worktrust` hook when WorkTrust set them) and the key file, and empties
|
|
145
246
|
`~/.worktrust` but for one small note of which account it fed (masked), so that a later coupling by
|
|
146
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
|
/**
|
|
@@ -277,7 +276,6 @@ function codexCoupling() {
|
|
|
277
276
|
return null;
|
|
278
277
|
}
|
|
279
278
|
|
|
280
|
-
|
|
281
279
|
/** Which layer a set of paths touched — the same plain reading the connector uses for classes. */
|
|
282
280
|
const LAYER_RULES = [
|
|
283
281
|
["database", /(^|\/)migrations?\/|\.sql$|(^|\/)(schema|seed)s?\.(prisma|sql|ts|rb)$/i],
|
|
@@ -633,7 +631,7 @@ async function send(door, payload) {
|
|
|
633
631
|
* matches commits by, and cutting on the calendar keeps `at` a real instant rather than an
|
|
634
632
|
* average of one. Younger than the live window is left alone — that is `log_work`'s half.
|
|
635
633
|
*/
|
|
636
|
-
function historyEntries(floor = null) {
|
|
634
|
+
function historyEntries(floor = null, all = false) {
|
|
637
635
|
const entries = [];
|
|
638
636
|
for (const { file } of allTranscripts()) {
|
|
639
637
|
const lines = parsedLines(file);
|
|
@@ -658,13 +656,13 @@ function historyEntries(floor = null) {
|
|
|
658
656
|
if (floor !== null && messages[0].at < floor) continue;
|
|
659
657
|
const last = messages[messages.length - 1].at;
|
|
660
658
|
// The live window belongs to the hook: a stretch that log_work may still report is not history.
|
|
661
|
-
if (Date.now() - last < WINDOW_HOURS * 3600_000) continue;
|
|
659
|
+
if (!all && Date.now() - last < WINDOW_HOURS * 3600_000) continue;
|
|
662
660
|
// The same measure as a live day (seconds, layers, tokens, turns, model); history differs only in the door it goes through.
|
|
663
661
|
const entry = payloadFor({ ...measure(cwd, messages), ref: stretchRef(file, day), steered, agents: agents.get(day) ?? null });
|
|
664
662
|
if (CODEX_ROLLOUT.test(file)) entrySource.set(entry, "codex");
|
|
665
663
|
if (ANTIGRAVITY_TRANSCRIPT.test(file)) entrySource.set(entry, "antigravity");
|
|
666
664
|
if (DATABASE_SESSION.test(file)) entrySource.set(entry, file.slice(0, file.indexOf(":")));
|
|
667
|
-
entryFile.set(entry, file);
|
|
665
|
+
entryFile.set(entry, { file, from: messages[0].at, folder: cwd ? String(cwd).split(/[\\/]/).filter(Boolean).pop() ?? null : null });
|
|
668
666
|
entries.push(entry);
|
|
669
667
|
}
|
|
670
668
|
}
|
|
@@ -853,7 +851,9 @@ const door = coupling();
|
|
|
853
851
|
const codexDoor = codexCoupling();
|
|
854
852
|
const doorFor = (entry) => (String(entry?.id ?? "").startsWith("codex:") ? codexDoor ?? door : door);
|
|
855
853
|
const entrySource = new WeakMap(); // an entry → "codex" or "antigravity" by the client that wrote it; the sends route and name by it
|
|
856
|
-
const entryFile = new WeakMap(); // a history entry → its transcript
|
|
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); }
|
|
857
857
|
if (!door && !codexDoor && !DRY) { process.exit(0); } // Not coupled on this machine: nothing to do, quietly.
|
|
858
858
|
|
|
859
859
|
if (flag("history")) {
|
|
@@ -862,7 +862,7 @@ if (flag("history")) {
|
|
|
862
862
|
const hours = entries.reduce((sum, entry) => sum + (entry.seconds ?? 0), 0) / 3600;
|
|
863
863
|
const months = [...new Set(entries.map((entry) => entry.at.slice(0, 7)))].sort();
|
|
864
864
|
// ONE LINE FOR `npx worktrust` TO ASK WITH (2026-10-03): sessions, hours, first and last month; nothing sent.
|
|
865
|
-
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); }
|
|
866
866
|
console.log(`${entries.length} day-stretches · ${hours.toFixed(1)} measured hours · ${months.join(", ")}`);
|
|
867
867
|
if (DRY) {
|
|
868
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.
|
|
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();
|
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.
|
|
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();
|