worktrust 0.7.6 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -81,6 +81,8 @@ yourself run a command that sends. See [Keep your history](#keep-your-history).
81
81
  npx worktrust@latest history send this computer's earlier sessions later (asks first)
82
82
  npx worktrust@latest history --rebuild re-measure them with the current rules; WorkTrust replaces this computer's
83
83
  earlier lines day by day, never counting a day twice (shows the plan, asks first)
84
+ npx worktrust@latest history --from-archive [dir] send what your local archive holds of sessions your AI apps
85
+ already deleted, marked as from your archive (shows the plan, asks first)
84
86
  npx worktrust codex let Codex wake the session hook (asks first)
85
87
  npx worktrust antigravity let Antigravity wake the session hook (asks first)
86
88
  npx worktrust@latest status what is coupled here
@@ -198,6 +200,13 @@ and more AI apps (Cursor, Copilot, Hermes, Goose, OpenCode, OpenClaw, Codex, Ant
198
200
  sent stand as they were measured then, and sending the sessions again would count those days twice: an old line
199
201
  does not name its stretch, so WorkTrust cannot tell it from the one that arrives.
200
202
 
203
+ `npx worktrust@latest history --from-archive` (0.8.0) is for the sessions an AI app has already deleted (Claude
204
+ Code's default is 30 days). It sends what your local archive (`preserve --archive`) holds of them, and only that: days
205
+ this computer signed with its own key, of sessions no longer on the computer, each line marked as coming from your
206
+ archive. It refuses, and sends nothing, when the computer has no device key, when the archive does not verify, or when
207
+ WorkTrust does not yet accept the mark. It shows the plan first and asks once; Enter is No. Such lines are history,
208
+ like any import: never verified hours.
209
+
201
210
  `npx worktrust@latest history --rebuild` re-measures every session still on this computer with the current rules and
202
211
  sends them with one difference: WorkTrust **replaces** this computer's earlier lines, day by day.
203
212
 
package/log-session.mjs CHANGED
@@ -54,12 +54,12 @@
54
54
  * CONFIDENTIAL PROJECTS never open: `~/.worktrust-counter.json` { "exclude": ["client-x"] } is
55
55
  * honoured here too, matched against the transcript's project directory, before a file is read.
56
56
  */
57
- import { execFileSync, spawn } from "node:child_process";
57
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
58
58
  import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
59
59
  import { homedir } from "node:os";
60
60
  import { dirname, join } from "node:path";
61
61
  import { fileURLToPath } from "node:url";
62
- import { createHash, randomBytes, sign as cryptoSign } from "node:crypto";
62
+ import { createHash, createPrivateKey, createPublicKey, randomBytes, sign as cryptoSign } from "node:crypto";
63
63
 
64
64
  const args = process.argv.slice(2);
65
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");
@@ -658,6 +658,19 @@ async function sendHistory(door, entries, announce = false, rebuild = false) {
658
658
  return accepted;
659
659
  }
660
660
 
661
+ /** Whether the door's import_history takes `from_archive` (0.8.0): asked with tools/list, never assumed. */
662
+ async function doorKnowsArchive(target) {
663
+ if (!target) return false;
664
+ try {
665
+ const body = JSON.stringify({ jsonrpc: "2.0", id: 0, method: "tools/list" });
666
+ const response = await fetch(target.url, { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${target.token}`, ...proofFor(target.url, body) }, body });
667
+ const text = await response.text();
668
+ const json = JSON.parse(text.trim().startsWith("{") ? text : text.split("\n").find((row) => row.startsWith("data:"))?.slice(5) ?? "null");
669
+ const tool = json?.result?.tools?.find((one) => one.name === "import_history");
670
+ return Boolean(tool?.inputSchema?.properties?.entries?.items?.properties?.from_archive);
671
+ } catch { return false; }
672
+ }
673
+
661
674
  /** DEVICE PROOF (2026-10-03): through `worktrust hook` a bound computer hands over WORKTRUST_DEVICE_KEY; every call is then signed. */
662
675
  const proofFor = (url, body) => {
663
676
  const device = process.env.WORKTRUST_DEVICE_KEY;
@@ -920,6 +933,32 @@ const entryFile = new WeakMap(); // a history entry → its transcript (`--summa
920
933
  if (flag("archive-lines")) { const lines = historyEntries(null, true).map((entry) => JSON.stringify({ ...entry, ...(DERIVED.get(entry) ?? {}), client: entrySource.get(entry) ?? "claude", started_at: new Date(entryFile.get(entry).from).toISOString(), folder: entryFile.get(entry).folder })); process.stdout.write(`${[...lines, JSON.stringify({ archive_end: lines.length })].join("\n")}\n`); await exitFlushed(0); } // one write and a count, so the reader tells a whole answer from a cut one
921
934
  if (!door && !codexDoor && !DRY) { process.exit(0); } // Not coupled on this machine: nothing to do, quietly.
922
935
 
936
+ /**
937
+ * HISTORY FROM THE ARCHIVE (0.8.0; framework §22). When an AI app has deleted its sessions, the stretches this computer
938
+ * archived (`preserve --archive`) can still be filed, marked `from_archive`. Five conditions, each refused by name: a
939
+ * device key (only lines this computer signed travel), an archive that verifies, a stretch whose session is gone from
940
+ * this computer (one still here goes through the ordinary history), a door that knows the mark, and the person asking.
941
+ */
942
+ if (flag("history") && flag("from-archive")) {
943
+ const named = value("from-archive"), dir = named && !named.startsWith("--") ? named.replace(/^~(?=\/|$)/, homedir()) : join(homedir(), "AI-Evidence");
944
+ const say = (text) => console.log(text);
945
+ const pem = process.env.WORKTRUST_DEVICE_KEY;
946
+ let ownKey = null;
947
+ try { ownKey = pem ? createPublicKey(createPrivateKey(pem)).export({ format: "jwk" }).x : null; } catch { ownKey = null; }
948
+ if (!ownKey) { say("No device key on this computer: only lines this computer signed can be sent from its archive. Nothing was sent."); await exitFlushed(1); }
949
+ const verified = spawnSync(process.execPath, [join(dirname(fileURLToPath(import.meta.url)), "preserve.mjs"), "--verify", dir], { encoding: "utf8" });
950
+ if (verified.status !== 0) { say(`The archive in ${dir} does not verify, so nothing was sent: ${(verified.stdout || verified.stderr || "").trim().split("\n").pop()}`); await exitFlushed(1); }
951
+ const { archiveEntries = null } = (await import("./preserve-lines.mjs").catch(() => null)) ?? {};
952
+ if (!archiveEntries) { say("preserve-lines.mjs is not beside this file. Nothing was sent."); await exitFlushed(1); }
953
+ const present = new Set(historyEntries(null, true).map((entry) => entry.stretch_ref));
954
+ const signed = archiveEntries(dir, ownKey), gone = signed.filter(({ entry }) => !present.has(entry.stretch_ref));
955
+ const entries = gone.map(({ entry, client }) => { if (client !== "claude") entrySource.set(entry, client); return entry; });
956
+ say(`${signed.length} stretches in the archive signed by this computer · ${entries.length} of them no longer in any AI app here`);
957
+ if (DRY || entries.length === 0) { if (entries[0]) say(`\nthe oldest of them, in full:\n${JSON.stringify(entries[0], null, 1)}`); say(entries.length === 0 ? "Nothing to send." : "\ndry run — nothing sent."); await exitFlushed(0); }
958
+ if (!(await doorKnowsArchive(door))) { say("WorkTrust does not accept history from an archive yet (the door does not know the mark). Nothing was sent."); await exitFlushed(1); }
959
+ await exitFlushed((await sendHistory(door, entries, true)) === entries.length ? 0 : 1);
960
+ }
961
+
923
962
  if (flag("history")) {
924
963
  const historyFloor = floorOf(readState(), "__historyFloor");
925
964
  const entries = historyEntries(historyFloor);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worktrust",
3
- "version": "0.7.6",
3
+ "version": "0.8.0",
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": {
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { spawnSync } from "node:child_process";
7
7
  import { createHash } from "node:crypto";
8
- import { existsSync } from "node:fs";
8
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
 
11
11
  const sha256 = (text) => createHash("sha256").update(text).digest("hex");
@@ -91,3 +91,34 @@ export function archiveLine(entry, behaviour = null, { ids = {}, collector } = {
91
91
  return { ...line, stretch_ref: entry.stretch_ref, ...ids, collector_version: collector };
92
92
  }
93
93
 
94
+
95
+ /**
96
+ * THE ARCHIVE AS HISTORY (0.8.0; framework §22: "preserve as evidence continuity"). The lines of an archive this
97
+ * computer wrote AND signed (a day whose proof carries this computer's key), as `import_history` entries, each marked
98
+ * `from_archive` so the record shows it came from the person's own archive after the AI app had deleted the session.
99
+ * The caller verifies the archive first, sends only stretches whose session is gone, and only to a door that knows the
100
+ * mark. Nothing here sends.
101
+ */
102
+ const read = (file) => { try { return JSON.parse(readFileSync(file, "utf8")); } catch { return null; } };
103
+ const readdir = (dir) => { try { return readdirSync(dir); } catch { return []; } };
104
+ export function archiveEntries(dir, ownKey) {
105
+ const out = [];
106
+ for (const year of readdir(dir).filter((name) => /^\d{4}$/.test(name)).sort()) for (const month of readdir(join(dir, year)).filter((name) => /^\d{2}$/.test(name)).sort()) {
107
+ const file = join(dir, year, month, "stretches.jsonl");
108
+ if (!existsSync(file)) continue;
109
+ for (const text of readFileSync(file, "utf8").split("\n").filter(Boolean)) {
110
+ let line; try { line = JSON.parse(text); } catch { continue; }
111
+ const proof = read(join(dir, "proofs", `${line.day}.json`));
112
+ if (!ownKey || !proof || typeof proof.signature !== "string" || proof.public_key !== ownKey) continue;
113
+ const entry = {
114
+ title: line.layer ? `AI-assisted work · ${line.layer}` : "AI-assisted work",
115
+ ...(line.kind ? { kind: line.kind } : {}), ...(line.layer ? { layer: line.layer } : {}), ...(line.layers ? { layers: line.layers } : {}),
116
+ ...(typeof line.seconds === "number" ? { seconds: line.seconds, duration_basis: line.duration_basis ?? "measured" } : {}),
117
+ ...Object.fromEntries(["tokens_in", "tokens_out", "token_basis", "tokens_cache_read", "tokens_cache_write", "exchanges", "turn_basis", "model", "steered_from", "model_seconds", "tool_seconds", "human_seconds", "idle_seconds", "agent_runs", "agent_seconds", "agent_peak", "interrupts", "steers", "utc_offset"].filter((key) => line[key] !== undefined).map((key) => [key, line[key]])),
118
+ stretch_ref: line.stretch_ref, started_at: line.started_at, at: line.ended_at, from_archive: true,
119
+ };
120
+ out.push({ entry, client: line.client ?? "claude" });
121
+ }
122
+ }
123
+ return out;
124
+ }
package/worktrust.mjs CHANGED
@@ -12,6 +12,8 @@
12
12
  * npx worktrust@latest history send this computer's earlier sessions as history (asks first)
13
13
  * npx worktrust@latest history --rebuild re-measure them with the current rules; WorkTrust replaces this computer's
14
14
  * earlier lines day by day, never counting a day twice (shows the plan, asks first)
15
+ * npx worktrust@latest history --from-archive [dir] send what your local archive holds of sessions your AI apps have
16
+ * already deleted, marked as from your archive (shows the plan, asks first)
15
17
  * npx worktrust@latest disconnect show what comes out, ask, take it out again
16
18
  * npx worktrust@latest status what is coupled here
17
19
  * npx worktrust@latest preserve how long each AI app here keeps its sessions (--apply keeps them,
@@ -71,7 +73,7 @@ const command = args.find((arg, at) => !arg.startsWith("--") && !(at > 0 && VALU
71
73
  const flag = (name) => { const at = args.indexOf(`--${name}`); return at >= 0 ? args[at + 1] : undefined; };
72
74
  const has = (name) => args.includes(`--${name}`);
73
75
  /** 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). */
74
- const CLI_VERSION = "0.7.6";
76
+ const CLI_VERSION = "0.8.0";
75
77
  const ORIGIN = (flag("origin") ?? process.env.WORKTRUST_ORIGIN ?? "https://app.worktrust.io").replace(/\/$/, "");
76
78
  const MCP = flag("url") ?? process.env.WORKTRUST_MCP_URL ?? `${ORIGIN}/api/mcp`;
77
79
  const HOME_DIR = join(homedir(), ".worktrust");
@@ -681,9 +683,27 @@ async function offerRebuild(previousVersion, paths, key) {
681
683
  async function history() {
682
684
  const key = await freshKey();
683
685
  if (!key) fail("this computer is not coupled through the key file. Run npx worktrust first.");
686
+ if (has("from-archive")) return offerArchive(await scripts(), key);
684
687
  await offerHistory(await scripts(), key.token, key.device ?? null, key.url, has("rebuild"));
685
688
  }
686
689
 
690
+ /**
691
+ * THE ARCHIVE AS HISTORY (0.8.0): the plan first (how many signed stretches, how many of them gone from the AI apps
692
+ * here), then one question, Enter is No; the hook refuses by name whatever does not hold (no device key, an archive
693
+ * that does not verify, a door that does not know the mark).
694
+ */
695
+ async function offerArchive(paths, key) {
696
+ const at = process.argv.indexOf("--from-archive"), named = process.argv[at + 1], dir = named && !named.startsWith("--") ? [named] : [];
697
+ const env = { WORKTRUST_MCP_URL: key.url, ...(key.device ? { WORKTRUST_DEVICE_KEY: key.device } : {}) };
698
+ const plan = await run(paths["log-session.mjs"], ["--history", "--from-archive", ...dir, "--dry-run"], key.token, true, env);
699
+ say();
700
+ for (const line of plan.out.trim().split("\n").filter((text) => !text.startsWith(" ") && !text.startsWith("{") && !text.startsWith("}") && text.trim())) say(` ${line}`);
701
+ if (plan.code !== 0 || /Nothing to send/.test(plan.out) || has("dry-run")) return;
702
+ if (!(await ask("Send them as history, marked as from your archive?", false))) { say(" Not sent."); return; }
703
+ const sent = await run(paths["log-session.mjs"], ["--history", "--from-archive", ...dir], key.token, true, env);
704
+ say(sent.code === 0 ? " ✓ Sent as history from your archive. It shows in WorkTrust as earlier work, marked as such, never as verified hours." : ` Not sent: ${sent.out.trim().split("\n").pop()}`);
705
+ }
706
+
687
707
  /**
688
708
  * CODEX WAKES THE HOOK ON A YES (owner, 2026-10-04). Codex has no hook settings: it runs ONE `notify`
689
709
  * program at the end of every turn, named on a top-level line of ~/.codex/config.toml. Without it
@@ -878,6 +898,8 @@ function help() {
878
898
  say(" worktrust history send this computer's earlier sessions as history (asks first)");
879
899
  say(" --rebuild re-measure them with the current rules; WorkTrust replaces this computer's earlier lines,");
880
900
  say(" day by day, never counting a day twice (shows the plan per AI app, asks first; --dry-run: the plan only)");
901
+ say(" --from-archive [dir] send what your archive (preserve --archive) holds of sessions your AI apps already deleted,");
902
+ say(" only days this computer signed, marked as from your archive (shows the plan, asks first)");
881
903
  say(" worktrust disconnect take WorkTrust out of every AI app here (asks first)");
882
904
  say(" worktrust status what is coupled here");
883
905
  say(" worktrust preserve keep your AI history: the report, then the settings and a local archive, each on your yes");