worktrust 0.6.16 → 0.6.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -2
- package/log-session.mjs +71 -31
- package/package.json +1 -1
- package/preserve.mjs +27 -12
- package/worktrust.mjs +42 -7
package/README.md
CHANGED
|
@@ -79,6 +79,8 @@ yourself run a command that sends. See [Keep your history](#keep-your-history).
|
|
|
79
79
|
|
|
80
80
|
```
|
|
81
81
|
npx worktrust@latest history send this computer's earlier sessions later (asks first)
|
|
82
|
+
npx worktrust@latest history --rebuild re-measure them with the current rules; WorkTrust replaces this computer's
|
|
83
|
+
earlier lines day by day, never counting a day twice (shows the plan, asks first)
|
|
82
84
|
npx worktrust codex let Codex wake the session hook (asks first)
|
|
83
85
|
npx worktrust antigravity let Antigravity wake the session hook (asks first)
|
|
84
86
|
npx worktrust@latest status what is coupled here
|
|
@@ -143,8 +145,10 @@ nothing it does not:
|
|
|
143
145
|
| `day`, `started_at`, `ended_at` | the day, and when the measured stretch began and ended |
|
|
144
146
|
| `seconds` | measured time: every gap between stamped events up to five minutes |
|
|
145
147
|
| `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 |
|
|
148
|
+
| `tokens_in`, `tokens_out`, `tokens_cache_read`, `tokens_cache_write` | token counts, when the app records them (one answer counted once, however many lines the app wrote it in) |
|
|
147
149
|
| `agent_runs`, `agent_seconds`, `agent_peak` | subagents: how many ran, their own time, the most open at once (never added to `seconds`) |
|
|
150
|
+
| `interrupts`, `steers` | how often you stopped the agent mid-answer, and how often your message arrived while it was still mid-task (counts, never the words) |
|
|
151
|
+
| `utc_offset` | your computer's offset from UTC when the stretch began, such as +02:00, so a working day reads in your own local time |
|
|
148
152
|
| `model` | the model's name as the app reported it |
|
|
149
153
|
| `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
154
|
| `exchanges` | how many times you and the model took turns |
|
|
@@ -179,6 +183,36 @@ and counts, exactly as the session hook reads it.
|
|
|
179
183
|
[worktrust.io/preserve](https://worktrust.io/preserve) is this package plus a guideline, for teams
|
|
180
184
|
that want the same record without WorkTrust.
|
|
181
185
|
|
|
186
|
+
## Rebuild the history
|
|
187
|
+
|
|
188
|
+
A newer version of this command measures a session differently from an older one: what the seconds were (the model
|
|
189
|
+
answering, tools running, your own turns, pauses), your own turns rather than every message, tokens with the cache,
|
|
190
|
+
and more AI apps (Cursor, Copilot, Hermes, Goose, OpenCode, OpenClaw, Codex, Antigravity). Lines an earlier version
|
|
191
|
+
sent stand as they were measured then, and sending the sessions again would count those days twice: an old line
|
|
192
|
+
does not name its stretch, so WorkTrust cannot tell it from the one that arrives.
|
|
193
|
+
|
|
194
|
+
`npx worktrust@latest history --rebuild` re-measures every session still on this computer with the current rules and
|
|
195
|
+
sends them with one difference: WorkTrust **replaces** this computer's earlier lines, day by day.
|
|
196
|
+
|
|
197
|
+
- **What it does.** It shows the plan first: per AI app, the days and the measured hours it would send, and the first
|
|
198
|
+
and last day. It asks once; Enter is No. `--dry-run` shows the plan and stops. Nothing is sent before a typed `y`.
|
|
199
|
+
For every day and AI app it carries, WorkTrust withdraws this computer's earlier lines (sent by this key or by an
|
|
200
|
+
earlier key of the same computer, live or as history), each with a correction row that says why, and then stores the
|
|
201
|
+
re-measured lines. A line that arrives unchanged is kept as it is. A day is counted once, and the same rebuild sent
|
|
202
|
+
twice changes nothing.
|
|
203
|
+
- **What it never does.** A replaced line is withdrawn and kept, never deleted: it stays in your record and the record shows
|
|
204
|
+
each replacement; deleting your account still removes all of it. Never touched: another computer's lines, what a web
|
|
205
|
+
connector (Claude or ChatGPT on the web) brought in, an AI app the batch does not carry, a day it does not carry, a line
|
|
206
|
+
you made private, a line less than 48 hours old (the session hook's own window), and a line received in the last 12
|
|
207
|
+
hours (the batches of one run never replace each other). It sends what `history` sends and nothing more: durations,
|
|
208
|
+
token counts, model names, a layer keyword, counts; never text.
|
|
209
|
+
- **Only from this computer's own key.** A connector (an AI signed in on the web) cannot ask for a rebuild; WorkTrust
|
|
210
|
+
refuses it at the door.
|
|
211
|
+
- **Offered once by `update`.** When this computer's earlier lines were made by an earlier version (the copy in
|
|
212
|
+
`~/.worktrust` says which), `npx worktrust@latest update` says so in plain words and offers the rebuild; Enter is No,
|
|
213
|
+
and the same version updated again offers nothing. The command cannot read your record, so it is an offer, not a
|
|
214
|
+
finding.
|
|
215
|
+
|
|
182
216
|
## Where the key lives, and why it only works here
|
|
183
217
|
|
|
184
218
|
The key is kept in **one file only you can read**: `~/.worktrust/key.json` (mode 600, in a folder of
|
|
@@ -205,7 +239,7 @@ which would delete every password on the computer, and many people rightly keep
|
|
|
205
239
|
| **Reads** | Which AI apps are installed (the settings folders of Claude Code, Codex, Cursor, Gemini CLI, Antigravity, VS Code, Windsurf, Hermes Agent, Goose, OpenCode, OpenClaw), and the chats Cursor and Copilot keep (clocks, models and token counts only). Afterwards, the session hook reads Claude Code's, Codex's and Antigravity's session files, the session databases of Hermes, Goose, OpenCode and OpenClaw, and the chats Cursor and Copilot keep, on this computer to measure durations and token counts. |
|
|
206
240
|
| **Writes** | The key file (above). In each AI app's MCP settings (`~/.claude.json` through `claude mcp`, `~/.cursor/mcp.json`, `~/.codex/config.toml`, `~/.gemini/settings.json`, `~/.gemini/config/mcp_config.json` (Antigravity), VS Code's `mcp.json`, Windsurf's `mcp_config.json`, Hermes's and Goose's `config.yaml`, OpenCode's `opencode.json`, OpenClaw through `openclaw mcp set`) a WorkTrust entry that runs the local bridge, with no key in it; three hooks in `~/.claude/settings.json`; the bridge, the hook and the counter in `~/.worktrust/`. |
|
|
207
241
|
| **Sends, to pair** | This computer's system (macOS, Windows, Linux) and host name. WorkTrust also records the network address the request arrives from, as a security record kept 90 days and seen only by WorkTrust staff (to answer a theft or fraud report). |
|
|
208
|
-
| **Sends, afterwards** | Per measured stretch of work, and nothing else: its durations (`seconds`, `duration_basis`, `model_seconds`, `tool_seconds`, `human_seconds`, `idle_seconds`), token counts (`tokens_in`, `tokens_out`, `tokens_cache_read`, `tokens_cache_write`, `token_basis`), the model's name (`model`), the kind of work and one layer keyword such as frontend or backend (`title`, `kind`, `layer`, `layers`), counts of how you work (`exchanges`, `turn_basis`, `agent_runs`, `agent_seconds`, `agent_peak`), when it ended (`at`), a hash of the session and the day (`stretch_ref`), `steered_from` when it ran over SSH, and, so the work can be matched to GitHub, the repository as its remote's owner/name (`repo`) and the hashes of the commits made in the stretch (`commits`, never their messages). |
|
|
242
|
+
| **Sends, afterwards** | Per measured stretch of work, and nothing else: its durations (`seconds`, `duration_basis`, `model_seconds`, `tool_seconds`, `human_seconds`, `idle_seconds`), token counts (`tokens_in`, `tokens_out`, `tokens_cache_read`, `tokens_cache_write`, `token_basis`), the model's name (`model`), the kind of work and one layer keyword such as frontend or backend (`title`, `kind`, `layer`, `layers`), counts of how you work (`exchanges`, `turn_basis`, `agent_runs`, `agent_seconds`, `agent_peak`, `interrupts`, `steers`), when it began and ended and your computer's offset from UTC (`started_at`, `at`, `utc_offset`), a hash of the session and the day (`stretch_ref`), `steered_from` when it ran over SSH, and, so the work can be matched to GitHub, the repository as its remote's owner/name (`repo`) and the hashes of the commits made in the stretch (`commits`, never their messages). |
|
|
209
243
|
| **Never sends** | Prompts, answers, code, file names or paths, commit messages, branch names, or the name of your local folder (a repository is named only as its remote's owner/name, above). The door has no field for them and refuses a submission that carries them. |
|
|
210
244
|
|
|
211
245
|
## Why you can check it
|
package/log-session.mjs
CHANGED
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
* node log-session.mjs --uninstall [--purge] take it out again (--purge also deletes the copies)
|
|
24
24
|
* node log-session.mjs --dry-run show what the current transcripts would send
|
|
25
25
|
* node log-session.mjs --history [--dry-run] send the months already on this machine, as HISTORY
|
|
26
|
-
* node log-session.mjs --history --
|
|
26
|
+
* node log-session.mjs --history --rebuild the same, re-measured: the door REPLACES this computer's earlier lines, day by day
|
|
27
|
+
* node log-session.mjs --history --summary one JSON line: sessions, hours, first and last month, per client (sends nothing)
|
|
27
28
|
* (as a hook) reads the hook's JSON on stdin, logs, exits
|
|
28
29
|
* (from Codex) notify = ["node", "~/.worktrust/log-session.mjs", "--codex-notify"]
|
|
29
30
|
* in ~/.codex/config.toml: Codex calls it at every turn's end and
|
|
@@ -61,11 +62,9 @@ import { fileURLToPath } from "node:url";
|
|
|
61
62
|
import { createHash, randomBytes, sign as cryptoSign } from "node:crypto";
|
|
62
63
|
|
|
63
64
|
const args = process.argv.slice(2);
|
|
64
|
-
const flag = (name) => args.includes(`--${name}`);
|
|
65
|
+
const flag = (name) => args.includes(`--${name}`), value = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : undefined; }, DRY = flag("dry-run");
|
|
65
66
|
// Exit once stdout is written: to a pipe it is asynchronous and process.exit drops what is queued (0.6.13 cut --archive-lines).
|
|
66
67
|
const exitFlushed = (code) => new Promise((resolve) => process.stdout.write("", resolve)).then(() => process.exit(code));
|
|
67
|
-
const value = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : undefined; };
|
|
68
|
-
const DRY = flag("dry-run");
|
|
69
68
|
|
|
70
69
|
/**
|
|
71
70
|
* THE OTHER HALF OF A SESSION: HOW IT WAS ASKED.
|
|
@@ -136,8 +135,7 @@ const DAY_SECONDS = 86400;
|
|
|
136
135
|
const TOOL_CAP = 1800;
|
|
137
136
|
/** Tools whose result waits for the person: the gap to it is the person's time. Names only; nothing of the call is read. */
|
|
138
137
|
const WAITS_FOR_PERSON = new Set(["AskUserQuestion", "ExitPlanMode"]);
|
|
139
|
-
const AGENT_RUNS_MAX = 10000;
|
|
140
|
-
const AGENT_SECONDS_MAX = 2592000;
|
|
138
|
+
const AGENT_RUNS_MAX = 10000, AGENT_SECONDS_MAX = 2592000;
|
|
141
139
|
|
|
142
140
|
/**
|
|
143
141
|
* WHEN THE HOOK RUNS. SessionEnd alone was a bet that sessions end, and a session that stays open
|
|
@@ -151,8 +149,7 @@ const AGENT_SECONDS_MAX = 2592000;
|
|
|
151
149
|
const HOOK_EVENTS = ["SessionEnd", "SessionStart", "PreCompact"];
|
|
152
150
|
|
|
153
151
|
const STATE = join(homedir(), ".worktrust", "sessions.json");
|
|
154
|
-
const readState = () => { try { return JSON.parse(readFileSync(STATE, "utf8")); } catch { return {}; } };
|
|
155
|
-
const writeState = (state) => { mkdirSync(dirname(STATE), { recursive: true }); writeFileSync(STATE, JSON.stringify(state, null, 1)); };
|
|
152
|
+
const readState = () => { try { return JSON.parse(readFileSync(STATE, "utf8")); } catch { return {}; } }, writeState = (state) => { mkdirSync(dirname(STATE), { recursive: true }); writeFileSync(STATE, JSON.stringify(state, null, 1)); };
|
|
156
153
|
/**
|
|
157
154
|
* THE FLOORS (owner, 2026-10-04: "history is never sent silently"). `__from` is the moment this
|
|
158
155
|
* computer was coupled: the hook sends nothing measured before it, so a fresh coupling (or one after
|
|
@@ -345,22 +342,45 @@ function stretchesOf(file, since) {
|
|
|
345
342
|
}
|
|
346
343
|
if (messages.length === 0) return [];
|
|
347
344
|
messages.sort((a, b) => a.at - b.at);
|
|
345
|
+
const byDay = byDayOf(messages);
|
|
346
|
+
const agents = agentsByDay(file, since);
|
|
347
|
+
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);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* DAYS SPLIT ON THE CLOCK, NOTHING LOST AT MIDNIGHT (0.6.18, audit C14). A stretch is one session's UTC day, and the gap
|
|
352
|
+
* between a day's last message and the next day's first belonged to neither, so a run through the night lost one gap
|
|
353
|
+
* per midnight. A day whose first message follows the last one within the tool cap now opens with a BRIDGE: the message
|
|
354
|
+
* before it, carrying no tokens, no model and no turn, so the gap that crosses midnight is counted once, on the day it
|
|
355
|
+
* ends, under the same caps as any other.
|
|
356
|
+
* Messages must arrive sorted.
|
|
357
|
+
*/
|
|
358
|
+
function byDayOf(messages) {
|
|
348
359
|
const byDay = new Map();
|
|
360
|
+
let prev = null;
|
|
349
361
|
for (const message of messages) {
|
|
350
362
|
const day = new Date(message.at).toISOString().slice(0, 10);
|
|
351
|
-
|
|
363
|
+
// Only a gap the work could have run through (within the tool cap): a session resumed the next morning opens its day as it always did.
|
|
364
|
+
if (!byDay.has(day)) byDay.set(day, prev && message.at - prev.at <= TOOL_CAP * 1000 ? [{ ...prev, usage: null, model: null, cumulative: false, bridge: true }] : []);
|
|
352
365
|
byDay.get(day).push(message);
|
|
366
|
+
prev = message;
|
|
353
367
|
}
|
|
354
|
-
|
|
355
|
-
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);
|
|
368
|
+
return byDay;
|
|
356
369
|
}
|
|
370
|
+
/** The first message a day really holds (a bridge is the day before's). */
|
|
371
|
+
const firstOf = (messages) => (messages.find((message) => !message.bridge) ?? messages[0]).at;
|
|
372
|
+
/** The computer's offset from UTC at a moment, as +02:00: what the owner's mirror needs to read a working day in local time. */
|
|
373
|
+
const offsetAt = (ms) => { const minutes = -new Date(ms).getTimezoneOffset(), abs = Math.abs(minutes); return `${minutes < 0 ? "-" : "+"}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`; };
|
|
374
|
+
/** Claude Code's own line when the person stops the agent mid-answer (Esc), read for its marker only. */
|
|
375
|
+
const INTERRUPTED = /^\[Request interrupted by user/;
|
|
376
|
+
const textOf = (content) => (typeof content === "string" ? content : Array.isArray(content) ? content.map((block) => (block && typeof block.text === "string" ? block.text : "")).join("") : "");
|
|
357
377
|
|
|
358
378
|
/** One transcript line as the measure reads it: the clock, the role, what KIND of message it is (for the layers), its counts. */
|
|
359
379
|
const messageOf = (line, at) => {
|
|
360
380
|
const content = line.message?.content ?? null;
|
|
361
381
|
const blocks = Array.isArray(content) ? content : [];
|
|
362
382
|
return {
|
|
363
|
-
at, type: line.type, usage: line.message?.usage ?? null, model: line.message?.model ?? null, meta: line.isMeta === true, content,
|
|
383
|
+
at, type: line.type, usage: line.message?.usage ?? null, model: line.message?.model ?? null, messageId: typeof line.message?.id === "string" ? line.message.id : null, meta: line.isMeta === true, content,
|
|
364
384
|
// A tool's answer comes back on the user role; a line a reader marks `agentic` is an agent run (Copilot's asked → completed).
|
|
365
385
|
kind: line.type === "assistant" ? "assistant" : blocks.some((block) => block && block.type === "tool_result") ? "tool_result" : "user",
|
|
366
386
|
toolUse: line.type === "assistant" && blocks.some((block) => block && block.type === "tool_use"),
|
|
@@ -459,12 +479,22 @@ function measure(cwd, messages) {
|
|
|
459
479
|
const models = new Map();
|
|
460
480
|
let exchanges = 0;
|
|
461
481
|
for (const message of messages) {
|
|
462
|
-
if (message.type === "user" && !message.meta && isHumanTurn(message.content)) exchanges += 1;
|
|
482
|
+
if (message.type === "user" && !message.meta && !message.bridge && isHumanTurn(message.content)) exchanges += 1;
|
|
463
483
|
if (message.model) models.set(message.model, (models.get(message.model) ?? 0) + 1);
|
|
464
484
|
}
|
|
465
485
|
const model = [...models.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
|
|
466
486
|
const tokens = tokensOf(messages);
|
|
467
|
-
|
|
487
|
+
// HOW THE PERSON STEERED (owner, 2026-10-08): how often they stopped the agent (Claude Code's interruption line), and how
|
|
488
|
+
// often their next turn arrived while the agent was still mid-task (right after a tool call or its result). Counts only.
|
|
489
|
+
let interrupts = 0, steers = 0;
|
|
490
|
+
for (let i = 1; i < messages.length; i += 1) {
|
|
491
|
+
const message = messages[i], prev = messages[i - 1];
|
|
492
|
+
if (message.bridge || message.type !== "user" || message.meta || message.kind === "tool_result") continue;
|
|
493
|
+
if (INTERRUPTED.test(textOf(message.content).trim())) interrupts += 1;
|
|
494
|
+
else if (isHumanTurn(message.content) && (prev.kind === "tool_result" || prev.toolUse)) steers += 1;
|
|
495
|
+
}
|
|
496
|
+
const from = firstOf(messages);
|
|
497
|
+
return { cwd, seconds: measured, layers: measured !== null ? timeLayers(messages) : null, ...tokens, cumulative: messages.some((message) => message.cumulative), exchanges, interrupts, steers, model, from, to: messages[messages.length - 1].at, offset: offsetAt(from) };
|
|
468
498
|
}
|
|
469
499
|
|
|
470
500
|
/**
|
|
@@ -511,9 +541,14 @@ function usageShapeKnown(usage) {
|
|
|
511
541
|
}
|
|
512
542
|
function tokensOf(messages) {
|
|
513
543
|
let tokensIn = 0, tokensOut = 0, cacheRead = 0, cacheWrite = 0, counted = 0, shapeKnown = true;
|
|
544
|
+
// ONE ANSWER, ONE USAGE (0.6.18): Claude Code writes an answer as a line per content block (thinking, text, each tool
|
|
545
|
+
// call), every one repeating the answer's usage; summed per line, a real session read 2.6 times its output tokens. An
|
|
546
|
+
// answer with an id is counted once; a line without one (Codex's token events) is its own.
|
|
547
|
+
const seen = new Set();
|
|
514
548
|
for (const message of messages) {
|
|
515
549
|
const usage = message.usage;
|
|
516
550
|
if (!usage) continue;
|
|
551
|
+
if (message.messageId) { if (seen.has(message.messageId)) continue; seen.add(message.messageId); }
|
|
517
552
|
if (!usageShapeKnown(usage)) { shapeKnown = false; break; }
|
|
518
553
|
counted += 1;
|
|
519
554
|
tokensIn += (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
|
|
@@ -568,6 +603,10 @@ function payloadFor(stretch) {
|
|
|
568
603
|
// THE LAYERS (2026-10-06), only on a measured stretch, each bounded by a day: what the seconds were. `seconds` above is untouched.
|
|
569
604
|
...(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) } : {}),
|
|
570
605
|
// THE SUBAGENTS, as agent metadata beside the day: runs, their own seconds, the peak open at once. Never in `seconds`.
|
|
606
|
+
// WHEN AND HOW (0.6.18): the stretch's first moment and the computer's UTC offset then; the person's interruptions and mid-task turns, counts only.
|
|
607
|
+
started_at: new Date(stretch.from).toISOString(), utc_offset: stretch.offset,
|
|
608
|
+
...(stretch.interrupts > 0 ? { interrupts: Math.min(10000, stretch.interrupts) } : {}),
|
|
609
|
+
...(stretch.steers > 0 ? { steers: Math.min(10000, stretch.steers) } : {}),
|
|
571
610
|
...(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) } : {}),
|
|
572
611
|
at: new Date(stretch.to).toISOString(),
|
|
573
612
|
};
|
|
@@ -580,7 +619,7 @@ function payloadFor(stretch) {
|
|
|
580
619
|
* exactly what landed. `announce` because one caller is a person watching a terminal and the other
|
|
581
620
|
* is a hook holding a session's exit open.
|
|
582
621
|
*/
|
|
583
|
-
async function sendHistory(door, entries, announce = false) {
|
|
622
|
+
async function sendHistory(door, entries, announce = false, rebuild = false) {
|
|
584
623
|
let accepted = 0;
|
|
585
624
|
// A Codex entry goes through Codex's own coupling where one exists (see codexDoor), the rest
|
|
586
625
|
// through the door given: the record files a line under the coupling that sent it. Antigravity's
|
|
@@ -590,9 +629,10 @@ async function sendHistory(door, entries, announce = false) {
|
|
|
590
629
|
if (group.length === 0 || !target) continue;
|
|
591
630
|
for (let i = 0; i < group.length; i += 200) {
|
|
592
631
|
const batch = group.slice(i, i + 200);
|
|
593
|
-
// History carries no shas: an imported line forms no session, and the join is the live route's.
|
|
594
|
-
|
|
595
|
-
const
|
|
632
|
+
// History carries no shas: an imported line forms no session, and the join is the live route's. REBUILD (owner, 2026-10-07): `rebuild: true`
|
|
633
|
+
// tells the door to replace this computer's earlier lines on the days and for the client the batch carries, and every group names its client.
|
|
634
|
+
const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "import_history", arguments: { ...(rebuild ? { rebuild: true } : {}), entries: batch.map((entry) => { const { commits, ...rest } = entry; void commits; return rest; }) } } });
|
|
635
|
+
const response = await fetch(target.url, { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${target.token}`, ...clientOf(source, rebuild), ...proofFor(target.url, body) }, body });
|
|
596
636
|
const text = await response.text();
|
|
597
637
|
if (announce) console.log(`batch ${Math.floor(i / 200) + 1}: ${response.status} ${text.slice(0, 200).replace(/\\n/g, " ")}`);
|
|
598
638
|
if (!response.ok) break;
|
|
@@ -616,7 +656,7 @@ const proofFor = (url, body) => {
|
|
|
616
656
|
* on this computer, and the door names a coupling's clients by the user-agent it hears (mcp_identify's
|
|
617
657
|
* agents_seen). An Antigravity line names Antigravity: the product name, nothing of the work.
|
|
618
658
|
*/
|
|
619
|
-
const clientOf = (source) => (source === "antigravity" || DATABASE_CLIENTS.includes(source) ? { "user-agent": `${source}/hook (worktrust-hook)` } : {});
|
|
659
|
+
const clientOf = (source, always = false) => (always || source === "antigravity" || DATABASE_CLIENTS.includes(source) ? { "user-agent": `${source ?? "claude"}/hook (worktrust-hook)` } : {});
|
|
620
660
|
|
|
621
661
|
async function send(door, payload) {
|
|
622
662
|
const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "log_work", arguments: payload } });
|
|
@@ -645,23 +685,22 @@ function historyEntries(floor = null, all = false) {
|
|
|
645
685
|
const lines = parsedLines(file);
|
|
646
686
|
if (!lines) continue;
|
|
647
687
|
let cwd = null, steered = null;
|
|
648
|
-
const
|
|
688
|
+
const read = [];
|
|
649
689
|
for (const line of lines) {
|
|
650
690
|
if (typeof line.cwd === "string") cwd = line.cwd;
|
|
651
691
|
if (typeof line.steered === "string") steered = line.steered;
|
|
652
692
|
if (!line.timestamp || (line.type !== "user" && line.type !== "assistant")) continue;
|
|
653
693
|
const at = Date.parse(line.timestamp);
|
|
654
694
|
if (!Number.isFinite(at)) continue;
|
|
655
|
-
|
|
656
|
-
if (!byDay.has(day)) byDay.set(day, []);
|
|
657
|
-
byDay.get(day).push(messageOf(line, at));
|
|
695
|
+
read.push(messageOf(line, at));
|
|
658
696
|
}
|
|
697
|
+
read.sort((a, b) => a.at - b.at);
|
|
698
|
+
const byDay = byDayOf(read);
|
|
659
699
|
const agents = agentsByDay(file, null);
|
|
660
700
|
for (const [day, messages] of byDay) {
|
|
661
701
|
if (messages.length === 0) continue;
|
|
662
|
-
messages.sort((a, b) => a.at - b.at);
|
|
663
702
|
// Under-reporting is the safe side (THE FLOORS): a day that began before the account changed is not offered at all.
|
|
664
|
-
if (floor !== null && messages
|
|
703
|
+
if (floor !== null && firstOf(messages) < floor) continue;
|
|
665
704
|
const last = messages[messages.length - 1].at;
|
|
666
705
|
// The live window belongs to the hook: a stretch that log_work may still report is not history.
|
|
667
706
|
if (!all && Date.now() - last < WINDOW_HOURS * 3600_000) continue;
|
|
@@ -670,7 +709,7 @@ function historyEntries(floor = null, all = false) {
|
|
|
670
709
|
if (CODEX_ROLLOUT.test(file)) entrySource.set(entry, "codex");
|
|
671
710
|
if (ANTIGRAVITY_TRANSCRIPT.test(file)) entrySource.set(entry, "antigravity");
|
|
672
711
|
if (DATABASE_SESSION.test(file)) entrySource.set(entry, file.slice(0, file.indexOf(":")));
|
|
673
|
-
entryFile.set(entry, { file, from: messages
|
|
712
|
+
entryFile.set(entry, { file, from: firstOf(messages), folder: cwd ? String(cwd).split(/[\\/]/).filter(Boolean).pop() ?? null : null });
|
|
674
713
|
entries.push(entry);
|
|
675
714
|
}
|
|
676
715
|
}
|
|
@@ -870,17 +909,18 @@ if (flag("history")) {
|
|
|
870
909
|
const entries = historyEntries(historyFloor);
|
|
871
910
|
const hours = entries.reduce((sum, entry) => sum + (entry.seconds ?? 0), 0) / 3600;
|
|
872
911
|
const months = [...new Set(entries.map((entry) => entry.at.slice(0, 7)))].sort();
|
|
873
|
-
// ONE LINE FOR `npx worktrust` TO ASK WITH (2026-10-03): sessions, hours, first and last month; nothing sent.
|
|
874
|
-
|
|
875
|
-
console.log(
|
|
912
|
+
// ONE LINE FOR `npx worktrust` TO ASK WITH (2026-10-03): sessions, hours, first and last month; per client its days and hours (the rebuild's plan, 2026-10-07); nothing sent.
|
|
913
|
+
const clients = [...entries.reduce((map, entry) => { const client = entrySource.get(entry) ?? "claude", row = map.get(client) ?? { client, days: new Set(), seconds: 0 }; row.days.add(entry.at.slice(0, 10)); row.seconds += entry.seconds ?? 0; return map.set(client, row); }, new Map()).values()].map((row) => ({ client: row.client, days: row.days.size, hours: Math.round(row.seconds / 360) / 10, first: [...row.days].sort()[0], last: [...row.days].sort().at(-1) }));
|
|
914
|
+
if (flag("summary")) { console.log(JSON.stringify({ sessions: new Set(entries.map((entry) => entryFile.get(entry).file)).size, hours: Math.round(hours * 10) / 10, first: months[0] ?? null, last: months.at(-1) ?? null, clients })); await exitFlushed(0); }
|
|
915
|
+
console.log(`${entries.length} day-stretches · ${hours.toFixed(1)} measured hours · ${months.join(", ")}${flag("rebuild") ? " · rebuild: this computer's earlier lines for these days and clients are replaced at the door" : ""}`);
|
|
876
916
|
if (DRY) {
|
|
877
917
|
// One example in full: the shape is the argument for trusting it, and a summary hides it.
|
|
878
918
|
if (entries[0]) console.log(`\nthe oldest of them, in full:\n${JSON.stringify(entries[0], null, 1)}`);
|
|
879
|
-
console.log(
|
|
919
|
+
console.log(`\ndry run — nothing sent. Add --history${flag("rebuild") ? " --rebuild" : ""} without --dry-run to ${flag("rebuild") ? "rebuild" : "import"} them.`);
|
|
880
920
|
await exitFlushed(0);
|
|
881
921
|
}
|
|
882
922
|
// A batch the door refused is a failure the caller must see, not a quiet zero.
|
|
883
|
-
await exitFlushed((await sendHistory(door, entries, true)) === entries.length ? 0 : 1);
|
|
923
|
+
await exitFlushed((await sendHistory(door, entries, true, flag("rebuild"))) === entries.length ? 0 : 1);
|
|
884
924
|
}
|
|
885
925
|
|
|
886
926
|
const input = hookInput();
|
package/package.json
CHANGED
package/preserve.mjs
CHANGED
|
@@ -168,7 +168,7 @@ async function apply(list, agreed = false) {
|
|
|
168
168
|
}
|
|
169
169
|
|
|
170
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"];
|
|
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", "interrupts", "steers"];
|
|
172
172
|
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
|
|
173
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
174
|
const WORDS = ["layer", "kind", "steered_from", "duration_basis", "token_basis", "turn_basis"];
|
|
@@ -195,6 +195,8 @@ function archiveLine(entry) {
|
|
|
195
195
|
for (const key of WORDS) if (typeof entry[key] === "string" && WORD.test(entry[key])) line[key] = entry[key];
|
|
196
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
197
|
if (typeof entry.exchanges === "number" && Number.isInteger(entry.exchanges) && entry.exchanges > 0) line.exchanges = entry.exchanges;
|
|
198
|
+
// The computer's offset from UTC when the stretch began (0.6.18), so a working day can be read in local time.
|
|
199
|
+
if (typeof entry.utc_offset === "string" && /^[+-]\d{2}:\d{2}$/.test(entry.utc_offset)) line.utc_offset = entry.utc_offset;
|
|
198
200
|
// Commit hashes the stretch made, never a message: what ties AI time to delivered work in the repository.
|
|
199
201
|
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
202
|
// 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.
|
|
@@ -244,6 +246,8 @@ function check(dir) {
|
|
|
244
246
|
}
|
|
245
247
|
const manifest = readJson(join(dir, "manifest.json")).value;
|
|
246
248
|
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)");
|
|
249
|
+
// THE MANIFEST'S SEAL (0.6.18, audit C9): a signed count and head, so a cut tail with a manifest edited to match is caught.
|
|
250
|
+
if (manifest && typeof manifest.signature === "string" && !signedBy(manifest)) return broken("manifest.json", "its signature does not verify with the key named in it (the count or the last line was changed)");
|
|
247
251
|
const days = new Map();
|
|
248
252
|
for (const line of lines) days.set(line.day, [...(days.get(line.day) ?? []), line]);
|
|
249
253
|
const mine = deviceKey()?.publicX ?? null;
|
|
@@ -255,15 +259,19 @@ function check(dir) {
|
|
|
255
259
|
if (!proof || proof.date !== day) return broken(where, "missing or unreadable");
|
|
256
260
|
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
261
|
if (proof.signature === null) continue;
|
|
258
|
-
|
|
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
|
+
if (!signedBy(proof)) return broken(where, "the signature does not verify with the key named in it");
|
|
262
263
|
signed += 1;
|
|
263
|
-
if (
|
|
264
|
+
if (proof.public_key === mine) ours += 1;
|
|
264
265
|
}
|
|
265
|
-
return { lines, days, signed, ours, manifest };
|
|
266
|
+
return { lines, days, signed, ours, manifest, proofs: new Map([...days.keys()].map((day) => [day, readJson(join(dir, "proofs", `${day}.json`)).value])) };
|
|
266
267
|
}
|
|
268
|
+
/** Whether a signed body (a day's proof, the manifest) verifies under the key it names. */
|
|
269
|
+
function signedBy(document) {
|
|
270
|
+
const { signature, ...body } = document;
|
|
271
|
+
try { return verify(null, Buffer.from(canonical(body)), createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: body.public_key }, format: "jwk" }), Buffer.from(signature, "base64url")); } catch { return false; }
|
|
272
|
+
}
|
|
273
|
+
/** Signed with this computer's key, or null: the body as it is signed. */
|
|
274
|
+
const sealed = (body, key) => ({ ...body, signature: key ? sign(null, Buffer.from(canonical(body)), key.privateKey).toString("base64url") : null });
|
|
267
275
|
|
|
268
276
|
function archive(dir) {
|
|
269
277
|
IDS = computerIds();
|
|
@@ -297,13 +305,19 @@ function archive(dir) {
|
|
|
297
305
|
prev = hashed.hash;
|
|
298
306
|
}
|
|
299
307
|
// A day that gained lines gets its root again over all of its lines; the lines themselves are never rewritten.
|
|
308
|
+
// THIS KEY SIGNS ONLY ITS OWN LINES (0.6.18, audit S7): a day is signed when every earlier line of it was already under
|
|
309
|
+
// this key's signature (or there were none); a day that came into the folder from elsewhere keeps an unsigned root.
|
|
300
310
|
const key = deviceKey();
|
|
311
|
+
const ours = (document) => !document || (key && typeof document.signature === "string" && document.public_key === key.publicX);
|
|
301
312
|
mkdirSync(join(dir, "proofs"), { recursive: true });
|
|
302
313
|
for (const day of new Set(fresh.map((line) => line.day))) {
|
|
303
|
-
const
|
|
304
|
-
|
|
314
|
+
const own = !existing.days?.has(day) || ours(existing.proofs?.get(day));
|
|
315
|
+
const body = proofBody(day, all.filter((line) => line.day === day), own ? key : null);
|
|
316
|
+
writeFileSync(join(dir, "proofs", `${day}.json`), `${JSON.stringify(sealed(body, own ? key : null), null, 1)}\n`);
|
|
305
317
|
}
|
|
306
|
-
|
|
318
|
+
// The manifest is sealed the same way: a new archive, or one whose manifest this key already signed.
|
|
319
|
+
const manifestKey = !existing.manifest || ours(existing.manifest) ? key : null;
|
|
320
|
+
if (fresh.length > 0 || !existing.manifest) writeFileSync(join(dir, "manifest.json"), `${JSON.stringify(sealed({ 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, ...(manifestKey ? { public_key: manifestKey.publicX } : {}) }, manifestKey), null, 1)}\n`);
|
|
307
321
|
say();
|
|
308
322
|
say(` ✓ ${fresh.length} new day-stretch${fresh.length === 1 ? "" : "es"} archived in ${dir}: it holds ${all.length}, the session hook measures ${measured.size} finished ones here now${key ? ", each day's root signed with this computer's device key" : ", unsigned (no device key on this computer)"}.`);
|
|
309
323
|
if (waiting > 0) say(` ${waiting} of today wait for tomorrow's run: a day is archived once it is over.`);
|
|
@@ -314,8 +328,9 @@ function verifyArchive(dir) {
|
|
|
314
328
|
const result = check(dir);
|
|
315
329
|
if (result.broken) { say(` BROKEN ${result.broken}`); process.exit(1); }
|
|
316
330
|
if (result.lines.length === 0) { say(` Nothing to verify in ${dir}.`); return; }
|
|
317
|
-
const
|
|
318
|
-
|
|
331
|
+
const daySeal = 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)`;
|
|
332
|
+
const seal = typeof result.manifest?.signature !== "string" ? "manifest unsigned" : result.manifest.public_key === deviceKey()?.publicX ? "manifest signed by this computer's key" : "manifest signed by another key named in it";
|
|
333
|
+
say(` OK ${result.lines.length} stretches · ${result.days.size} days · the chain is whole · every day's root matches · ${daySeal} · ${seal}`);
|
|
319
334
|
}
|
|
320
335
|
|
|
321
336
|
/** --summary: what a person or a team reads from the archive, counted locally (preserve-summary.mjs) once the chain checks out. */
|
package/worktrust.mjs
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* npx worktrust@latest connect couple again, also on a computer already coupled
|
|
11
11
|
* npx worktrust@latest update this computer onto the newest CLI, same key, no new pairing
|
|
12
12
|
* npx worktrust@latest history send this computer's earlier sessions as history (asks first)
|
|
13
|
+
* npx worktrust@latest history --rebuild re-measure them with the current rules; WorkTrust replaces this computer's
|
|
14
|
+
* earlier lines day by day, never counting a day twice (shows the plan, asks first)
|
|
13
15
|
* npx worktrust@latest disconnect show what comes out, ask, take it out again
|
|
14
16
|
* npx worktrust@latest status what is coupled here
|
|
15
17
|
* npx worktrust@latest preserve how long each AI app here keeps its sessions (--apply keeps them,
|
|
@@ -69,7 +71,7 @@ const command = args.find((arg, at) => !arg.startsWith("--") && !(at > 0 && VALU
|
|
|
69
71
|
const flag = (name) => { const at = args.indexOf(`--${name}`); return at >= 0 ? args[at + 1] : undefined; };
|
|
70
72
|
const has = (name) => args.includes(`--${name}`);
|
|
71
73
|
/** This CLI's version, said to the door so the app can tell which computer runs an old one (check-cli-package holds it equal to package.json). */
|
|
72
|
-
const CLI_VERSION = "0.6.
|
|
74
|
+
const CLI_VERSION = "0.6.18";
|
|
73
75
|
const ORIGIN = (flag("origin") ?? process.env.WORKTRUST_ORIGIN ?? "https://app.worktrust.io").replace(/\/$/, "");
|
|
74
76
|
const MCP = flag("url") ?? process.env.WORKTRUST_MCP_URL ?? `${ORIGIN}/api/mcp`;
|
|
75
77
|
const HOME_DIR = join(homedir(), ".worktrust");
|
|
@@ -532,6 +534,7 @@ async function update() {
|
|
|
532
534
|
if (has("dry-run")) { say(" Dry run: nothing was written and nothing was sent."); return; }
|
|
533
535
|
if (!(await ask("Continue?"))) { say(" Stopped. Nothing was written."); return; }
|
|
534
536
|
ensureHome();
|
|
537
|
+
const previousVersion = stableVersion();
|
|
535
538
|
copyFileSync(fileURLToPath(import.meta.url), STABLE);
|
|
536
539
|
say(` ✓ ${STABLE} is now ${CLI_VERSION}`);
|
|
537
540
|
// Out first, then in: an app that held a key of its own (a hand-made entry) moves onto the bridge.
|
|
@@ -542,6 +545,7 @@ async function update() {
|
|
|
542
545
|
// Codex installed since, or a No said at connect: the one question, asked again (or "already set").
|
|
543
546
|
await offerCodex();
|
|
544
547
|
await offerAntigravity();
|
|
548
|
+
await offerRebuild(previousVersion, paths, key);
|
|
545
549
|
say();
|
|
546
550
|
say(" Done. Quit your AI apps completely and open them again; the CLI version shows in WorkTrust");
|
|
547
551
|
say(" under Sources → CLI once one of them has spoken. A key an app held by hand still works until you end it there.");
|
|
@@ -631,24 +635,53 @@ async function connect() {
|
|
|
631
635
|
* Enter, --yes and a closed terminal all mean No; `--history` says yes in advance. What goes is hours
|
|
632
636
|
* and tokens per day, filed as history (never verified hours, never text).
|
|
633
637
|
*/
|
|
634
|
-
async function offerHistory(paths, token, devicePem, url = MCP) {
|
|
638
|
+
async function offerHistory(paths, token, devicePem, url = MCP, rebuild = false) {
|
|
635
639
|
const env = { WORKTRUST_MCP_URL: url, ...(devicePem ? { WORKTRUST_DEVICE_KEY: devicePem } : {}) };
|
|
636
640
|
const counted = await run(paths["log-session.mjs"], ["--history", "--dry-run", "--summary"], token, true, env);
|
|
637
641
|
let found = null;
|
|
638
642
|
try { found = JSON.parse(counted.out.trim().split("\n").pop()); } catch { /* no line: nothing to offer */ }
|
|
639
643
|
say();
|
|
640
|
-
if (!found?.sessions) { say(" Your AI apps hold no earlier sessions on this computer to send."); return; }
|
|
644
|
+
if (!found?.sessions) { say(rebuild ? " Your AI apps hold no earlier sessions on this computer to re-measure." : " Your AI apps hold no earlier sessions on this computer to send."); return; }
|
|
641
645
|
say(` This computer holds ${found.sessions} earlier sessions of your AI apps · ${found.hours} measured hours · ${found.first} … ${found.last}.`);
|
|
646
|
+
// REBUILD (owner, 2026-10-07): the plan per AI app, in days and hours, and what it replaces, before the one question. Enter is No.
|
|
647
|
+
if (rebuild) {
|
|
648
|
+
for (const row of found.clients ?? []) say(` ${row.client.padEnd(12)} ${String(row.days).padStart(4)} days ${String(row.hours).padStart(7)} h ${row.first} … ${row.last}`);
|
|
649
|
+
say(" Re-measured with the current rules (what the seconds were, your own turns, tokens with the cache, every AI app above).");
|
|
650
|
+
say(" WorkTrust REPLACES this computer's earlier lines for those days and apps: each earlier line is withdrawn and kept,");
|
|
651
|
+
say(" never deleted, so a day is counted once. Other computers, web sources and days not listed are not touched.");
|
|
652
|
+
}
|
|
642
653
|
if (has("dry-run")) { say(" Dry run: nothing was sent."); return; }
|
|
643
|
-
if (!has("history") && !(await ask("Send these as history (hours and tokens per day; never text)?", false))) { say(" Not sent. Send them later with: npx worktrust@latest history"); return; }
|
|
644
|
-
const sent = await run(paths["log-session.mjs"], ["--history"], token, true, env);
|
|
645
|
-
say(sent.code === 0 ? " ✓
|
|
654
|
+
if (!has("history") && !(rebuild ? await ask("Rebuild the history (replaces this computer's earlier lines for these days)?", false) : await ask("Send these as history (hours and tokens per day; never text)?", false))) { say(rebuild ? " Not sent. Rebuild later with: npx worktrust@latest history --rebuild" : " Not sent. Send them later with: npx worktrust@latest history"); return; }
|
|
655
|
+
const sent = await run(paths["log-session.mjs"], ["--history", ...(rebuild ? ["--rebuild"] : [])], token, true, env);
|
|
656
|
+
if (rebuild) say(sent.code === 0 ? " ✓ Rebuilt. This computer's earlier lines for those days were replaced; the record shows each replacement." : " Not all of it arrived. Run npx worktrust@latest history --rebuild again: a day already rebuilt is counted once.");
|
|
657
|
+
else say(sent.code === 0 ? " ✓ Sent as history. It shows in WorkTrust as earlier work, never as verified hours." : " Not all of it arrived. Run npx worktrust@latest history again: a line already received is kept once.");
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** The version stamped in the stable copy in ~/.worktrust, the CLI this computer ran before an update; null without one. */
|
|
661
|
+
const stableVersion = () => { try { return /const CLI_VERSION = "([^"]+)";/.exec(readFileSync(STABLE, "utf8"))?.[1] ?? null; } catch { return null; } };
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* OFFERED ONCE, BY `update`, IN PLAIN WORDS (owner, 2026-10-07): when the computer's earlier lines were made by an earlier CLI
|
|
665
|
+
* (the version the stable copy carried before this update). The CLI cannot read the record, so it is an offer, not a
|
|
666
|
+
* finding: the plan is shown and the question asked by the same path as `history --rebuild`; Enter is No. The same
|
|
667
|
+
* version updated again offers nothing.
|
|
668
|
+
*/
|
|
669
|
+
async function offerRebuild(previousVersion, paths, key) {
|
|
670
|
+
if (!previousVersion || previousVersion === CLI_VERSION) return;
|
|
671
|
+
say();
|
|
672
|
+
say(` This computer's earlier lines in WorkTrust were measured by worktrust ${previousVersion}. Version ${CLI_VERSION} measures`);
|
|
673
|
+
say(" differently (each answer's tokens counted once, nothing lost at midnight, when each stretch began, how you steered");
|
|
674
|
+
say(" the agent, what the seconds were, more AI apps). You can re-measure the");
|
|
675
|
+
say(" sessions still on this computer and have WorkTrust replace this computer's earlier lines, day by day, never counting");
|
|
676
|
+
say(" a day twice. Later: npx worktrust@latest history --rebuild");
|
|
677
|
+
if (!(await ask("Show the plan and rebuild now?", false))) { say(" Not now."); return; }
|
|
678
|
+
await offerHistory(paths, key.token, key.device ?? null, key.url, true);
|
|
646
679
|
}
|
|
647
680
|
|
|
648
681
|
async function history() {
|
|
649
682
|
const key = await freshKey();
|
|
650
683
|
if (!key) fail("this computer is not coupled through the key file. Run npx worktrust first.");
|
|
651
|
-
await offerHistory(await scripts(), key.token, key.device ?? null, key.url);
|
|
684
|
+
await offerHistory(await scripts(), key.token, key.device ?? null, key.url, has("rebuild"));
|
|
652
685
|
}
|
|
653
686
|
|
|
654
687
|
/**
|
|
@@ -843,6 +876,8 @@ function help() {
|
|
|
843
876
|
say(" --replace yes in advance to coupling a computer that is already coupled");
|
|
844
877
|
say(" worktrust update this computer onto the newest CLI: same key, no new pairing");
|
|
845
878
|
say(" worktrust history send this computer's earlier sessions as history (asks first)");
|
|
879
|
+
say(" --rebuild re-measure them with the current rules; WorkTrust replaces this computer's earlier lines,");
|
|
880
|
+
say(" day by day, never counting a day twice (shows the plan per AI app, asks first; --dry-run: the plan only)");
|
|
846
881
|
say(" worktrust disconnect take WorkTrust out of every AI app here (asks first)");
|
|
847
882
|
say(" worktrust status what is coupled here");
|
|
848
883
|
say(" worktrust preserve keep your AI history: the report, then the settings and a local archive, each on your yes");
|