worktrust 0.7.6 → 0.8.1

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
@@ -165,6 +167,7 @@ nothing it does not:
165
167
  | `context`, `routing` | how often the context was compacted and which context commands you used (compact, clear, resume, context, model, memory; never a command of your own), how many models answered and how often the model changed (0.7.4) |
166
168
  | `steering` | the moments you stepped in while the agent worked (stopping it, or a message mid-task), how many changed what the agent did next, and how many were followed by a passing check (0.7.5) |
167
169
  | `tools`, `complexity` | how many different tools the agent used, and the stretch's task complexity C1 to C5 by the rule complexity/1 (points for layers touched, tools, subagents, duration, a recovered failure, kinds of check and a delivery; the rule is in stretch-evidence.mjs), with its points and the rule's version (0.7.6) |
170
+ | `oversight`, `planning`, `changes` | how often you refused an action or a plan the agent proposed, and how often a guard (the client's safety layer) refused one; how often the agent wrote its to-do list, the most items, and of the last list how many were done (never what they say); how many files the stretch's commits changed and how many of them tests (never a path) (0.8.1) |
168
171
  | `collector_version` | the CLI version that measured it |
169
172
  | `seq`, `prev`, `hash` | the line's place, the hash of the line before it, and its own hash |
170
173
 
@@ -198,6 +201,13 @@ and more AI apps (Cursor, Copilot, Hermes, Goose, OpenCode, OpenClaw, Codex, Ant
198
201
  sent stand as they were measured then, and sending the sessions again would count those days twice: an old line
199
202
  does not name its stretch, so WorkTrust cannot tell it from the one that arrives.
200
203
 
204
+ `npx worktrust@latest history --from-archive` (0.8.0) is for the sessions an AI app has already deleted (Claude
205
+ Code's default is 30 days). It sends what your local archive (`preserve --archive`) holds of them, and only that: days
206
+ this computer signed with its own key, of sessions no longer on the computer, each line marked as coming from your
207
+ archive. It refuses, and sends nothing, when the computer has no device key, when the archive does not verify, or when
208
+ WorkTrust does not yet accept the mark. It shows the plan first and asks once; Enter is No. Such lines are history,
209
+ like any import: never verified hours.
210
+
201
211
  `npx worktrust@latest history --rebuild` re-measures every session still on this computer with the current rules and
202
212
  sends them with one difference: WorkTrust **replaces** this computer's earlier lines, day by day.
203
213
 
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");
@@ -135,6 +135,8 @@ const DAY_SECONDS = 86400;
135
135
  const TOOL_CAP = 1800;
136
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. */
137
137
  const WAITS_FOR_PERSON = new Set(["AskUserQuestion", "ExitPlanMode"]);
138
+ /** A path that holds tests, by the conventions of the common test runners (read here; the path never leaves). */
139
+ const TEST_PATH = /(^|[\\/])(__tests__|tests?|spec|e2e)[\\/]|\.(test|spec)\.[cm]?[jt]sx?$|_test\.(go|py|rb)$|(^|[\\/])test_[^\\/]+\.py$|Tests?\.(java|kt|cs|swift)$/;
138
140
  /** The derived record of a stretch, kept for the local archive only (see payloadFor). */
139
141
  const DERIVED = new WeakMap();
140
142
  const AGENT_RUNS_MAX = 10000, AGENT_SECONDS_MAX = 2592000;
@@ -211,7 +213,7 @@ const excluded = (path) => excludes.some((text) => text && path.includes(text));
211
213
  const { CODEX_ROLLOUT = /(?!)/, codexIdOf = (file) => String(file).split(/[\\/]/).at(-1).replace(/\.jsonl$/, ""), antigravityIdOf = (file) => String(file).split(/[\\/]/).at(-4), codexLines, codexRolloutFiles = function* () {}, codexCwd = () => null, ANTIGRAVITY_TRANSCRIPT = /(?!)/, antigravityLines, antigravityRoots = () => [], antigravityTranscripts = function* () {}, antigravityContext = () => ({}), rememberAntigravity = () => null } = (await import("./transcript-readers.mjs").catch(() => null)) ?? {};
212
214
  // THE STRETCH'S DERIVED RECORD (0.7.4: split from this file): tool calls named by kind, verification, recovery, delegation,
213
215
  // context and routing, for the local archive only. A copy without the module still measures and sends exactly as before.
214
- const { callOf = (block) => ({ id: block.id, kinds: [], family: String(block.name), digest: "" }), deriveStretch = () => ({}), complexityOf = null } = (await import("./stretch-evidence.mjs").catch(() => null)) ?? {};
216
+ const { callOf = (block) => ({ id: block.id, kinds: [], family: String(block.name), digest: "" }), deriveStretch = () => ({}), complexityOf = null, resultOf = (block) => ({ id: block.tool_use_id, failed: block.is_error === true, refusal: null }) } = (await import("./stretch-evidence.mjs").catch(() => null)) ?? {};
215
217
  const { DATABASE_SESSION = /(?!)/, databaseLines = () => null, databaseSessions = function* () {} } = (await import("./session-databases.mjs").catch(() => null)) ?? {};
216
218
  const DATABASE_CLIENTS = ["hermes", "goose", "opencode", "openclaw", "cursor", "copilot"]; // the registry's keys, the name each line carries
217
219
  const LIVE_SOURCE = new RegExp(`^(codex|antigravity|${DATABASE_CLIENTS.join("|")}):`); // a sweep entry's id → the client it names (every database client, 0.6.16)
@@ -398,7 +400,7 @@ const messageOf = (line, at) => {
398
400
  // 0.7.1: each tool call's kinds (never its command) and each tool result's outcome, joined on the call's id in `measure`.
399
401
  // 0.7.2: and the call's FAMILY (the tool, and its check kinds) and a digest of its input, compared here and never kept.
400
402
  calls: line.type === "assistant" ? blocks.filter((block) => block && block.type === "tool_use" && typeof block.id === "string").map(callOf) : [],
401
- results: blocks.filter((block) => block && block.type === "tool_result" && typeof block.tool_use_id === "string").map((block) => ({ id: block.tool_use_id, failed: block.is_error === true })),
403
+ results: blocks.filter((block) => block && block.type === "tool_result" && typeof block.tool_use_id === "string").map(resultOf),
402
404
  };
403
405
  };
404
406
 
@@ -624,7 +626,9 @@ function payloadFor(stretch) {
624
626
  };
625
627
  // WHAT STAYS ON THIS COMPUTER (0.7.1): the stretch's derived record rides beside the payload for the local archive, never in it.
626
628
  // 0.7.6: and the stretch's complexity class, read from what the hook knows here (layers, subagents, duration) and the record.
627
- if (stretch.derived) DERIVED.set(payload, { ...stretch.derived, ...(complexityOf ? { complexity: complexityOf(stretch.derived, { layers: Math.max(layers.length, layer ? 1 : 0), agentRuns: stretch.agents?.runs ?? 0, agentPeak: stretch.agents?.peak ?? 0, seconds: stretch.seconds }) } : {}) });
629
+ // 0.8.1: what the stretch changed, as counts of files (never their paths): how many, and how many of them tests.
630
+ const changed = [...new Set(touched.paths)], tests = changed.filter((path) => TEST_PATH.test(path)).length;
631
+ if (stretch.derived) DERIVED.set(payload, { ...stretch.derived, ...(changed.length > 0 ? { changes: { files: changed.length, test_files: tests } } : {}), ...(complexityOf ? { complexity: complexityOf(stretch.derived, { layers: Math.max(layers.length, layer ? 1 : 0), agentRuns: stretch.agents?.runs ?? 0, agentPeak: stretch.agents?.peak ?? 0, seconds: stretch.seconds }) } : {}) });
628
632
  return payload;
629
633
  }
630
634
 
@@ -658,6 +662,19 @@ async function sendHistory(door, entries, announce = false, rebuild = false) {
658
662
  return accepted;
659
663
  }
660
664
 
665
+ /** Whether the door's import_history takes `from_archive` (0.8.0): asked with tools/list, never assumed. */
666
+ async function doorKnowsArchive(target) {
667
+ if (!target) return false;
668
+ try {
669
+ const body = JSON.stringify({ jsonrpc: "2.0", id: 0, method: "tools/list" });
670
+ 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 });
671
+ const text = await response.text();
672
+ const json = JSON.parse(text.trim().startsWith("{") ? text : text.split("\n").find((row) => row.startsWith("data:"))?.slice(5) ?? "null");
673
+ const tool = json?.result?.tools?.find((one) => one.name === "import_history");
674
+ return Boolean(tool?.inputSchema?.properties?.entries?.items?.properties?.from_archive);
675
+ } catch { return false; }
676
+ }
677
+
661
678
  /** DEVICE PROOF (2026-10-03): through `worktrust hook` a bound computer hands over WORKTRUST_DEVICE_KEY; every call is then signed. */
662
679
  const proofFor = (url, body) => {
663
680
  const device = process.env.WORKTRUST_DEVICE_KEY;
@@ -920,6 +937,32 @@ const entryFile = new WeakMap(); // a history entry → its transcript (`--summa
920
937
  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
938
  if (!door && !codexDoor && !DRY) { process.exit(0); } // Not coupled on this machine: nothing to do, quietly.
922
939
 
940
+ /**
941
+ * HISTORY FROM THE ARCHIVE (0.8.0; framework §22). When an AI app has deleted its sessions, the stretches this computer
942
+ * archived (`preserve --archive`) can still be filed, marked `from_archive`. Five conditions, each refused by name: a
943
+ * device key (only lines this computer signed travel), an archive that verifies, a stretch whose session is gone from
944
+ * this computer (one still here goes through the ordinary history), a door that knows the mark, and the person asking.
945
+ */
946
+ if (flag("history") && flag("from-archive")) {
947
+ const named = value("from-archive"), dir = named && !named.startsWith("--") ? named.replace(/^~(?=\/|$)/, homedir()) : join(homedir(), "AI-Evidence");
948
+ const say = (text) => console.log(text);
949
+ const pem = process.env.WORKTRUST_DEVICE_KEY;
950
+ let ownKey = null;
951
+ try { ownKey = pem ? createPublicKey(createPrivateKey(pem)).export({ format: "jwk" }).x : null; } catch { ownKey = null; }
952
+ 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); }
953
+ const verified = spawnSync(process.execPath, [join(dirname(fileURLToPath(import.meta.url)), "preserve.mjs"), "--verify", dir], { encoding: "utf8" });
954
+ 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); }
955
+ const { archiveEntries = null } = (await import("./preserve-lines.mjs").catch(() => null)) ?? {};
956
+ if (!archiveEntries) { say("preserve-lines.mjs is not beside this file. Nothing was sent."); await exitFlushed(1); }
957
+ const present = new Set(historyEntries(null, true).map((entry) => entry.stretch_ref));
958
+ const signed = archiveEntries(dir, ownKey), gone = signed.filter(({ entry }) => !present.has(entry.stretch_ref));
959
+ const entries = gone.map(({ entry, client }) => { if (client !== "claude") entrySource.set(entry, client); return entry; });
960
+ say(`${signed.length} stretches in the archive signed by this computer · ${entries.length} of them no longer in any AI app here`);
961
+ 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); }
962
+ 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); }
963
+ await exitFlushed((await sendHistory(door, entries, true)) === entries.length ? 0 : 1);
964
+ }
965
+
923
966
  if (flag("history")) {
924
967
  const historyFloor = floorOf(readState(), "__historyFloor");
925
968
  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.1",
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");
@@ -83,6 +83,14 @@ export function archiveLine(entry, behaviour = null, { ids = {}, collector } = {
83
83
  // TOOL BREADTH AND COMPLEXITY (0.7.6): distinct tools; a class C1–C5 with its points and the rule's version.
84
84
  if (whole(entry.tools) && entry.tools > 0) line.tools = entry.tools;
85
85
  if (entry.complexity && /^C[1-5]$/.test(entry.complexity.class ?? "") && whole(entry.complexity.points) && /^complexity\/\d+$/.test(entry.complexity.rule ?? "")) line.complexity = { class: entry.complexity.class, points: entry.complexity.points, rule: entry.complexity.rule };
86
+ // OVERSIGHT, PLANNING AND CHANGES (0.8.1): refusals by the person and by a guard; the agent's to-do lists; files changed and test files among them.
87
+ const pick = (record, keys) => (record && typeof record === "object" && keys.every((key) => whole(record[key])) ? Object.fromEntries(keys.map((key) => [key, record[key]])) : null);
88
+ const oversight = pick(entry.oversight, ["refused", "plans_rejected", "guard_denied"]);
89
+ if (oversight) line.oversight = oversight;
90
+ const planning = pick(entry.planning, ["lists", "items_max", "items_last", "done_last"]);
91
+ if (planning && planning.lists > 0 && planning.done_last <= planning.items_last && planning.items_last <= planning.items_max) line.planning = planning;
92
+ const changes = pick(entry.changes, ["files", "test_files"]);
93
+ if (changes && changes.test_files <= changes.files) line.changes = changes;
86
94
  // The behaviour signals of this stretch (0.7.0): keys of the counter's rubric and whole counts, never a word of a turn.
87
95
  if (behaviour && behaviour.signals && typeof behaviour.analyzer_version === "string" && /^counter@\d+\.\d+\.\d+$/.test(behaviour.analyzer_version)) {
88
96
  const kept = Object.entries(behaviour.signals).filter(([key, count]) => SIGNAL.test(key) && Number.isInteger(count) && count > 0).sort();
@@ -91,3 +99,34 @@ export function archiveLine(entry, behaviour = null, { ids = {}, collector } = {
91
99
  return { ...line, stretch_ref: entry.stretch_ref, ...ids, collector_version: collector };
92
100
  }
93
101
 
102
+
103
+ /**
104
+ * THE ARCHIVE AS HISTORY (0.8.0; framework §22: "preserve as evidence continuity"). The lines of an archive this
105
+ * computer wrote AND signed (a day whose proof carries this computer's key), as `import_history` entries, each marked
106
+ * `from_archive` so the record shows it came from the person's own archive after the AI app had deleted the session.
107
+ * The caller verifies the archive first, sends only stretches whose session is gone, and only to a door that knows the
108
+ * mark. Nothing here sends.
109
+ */
110
+ const read = (file) => { try { return JSON.parse(readFileSync(file, "utf8")); } catch { return null; } };
111
+ const readdir = (dir) => { try { return readdirSync(dir); } catch { return []; } };
112
+ export function archiveEntries(dir, ownKey) {
113
+ const out = [];
114
+ 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()) {
115
+ const file = join(dir, year, month, "stretches.jsonl");
116
+ if (!existsSync(file)) continue;
117
+ for (const text of readFileSync(file, "utf8").split("\n").filter(Boolean)) {
118
+ let line; try { line = JSON.parse(text); } catch { continue; }
119
+ const proof = read(join(dir, "proofs", `${line.day}.json`));
120
+ if (!ownKey || !proof || typeof proof.signature !== "string" || proof.public_key !== ownKey) continue;
121
+ const entry = {
122
+ title: line.layer ? `AI-assisted work · ${line.layer}` : "AI-assisted work",
123
+ ...(line.kind ? { kind: line.kind } : {}), ...(line.layer ? { layer: line.layer } : {}), ...(line.layers ? { layers: line.layers } : {}),
124
+ ...(typeof line.seconds === "number" ? { seconds: line.seconds, duration_basis: line.duration_basis ?? "measured" } : {}),
125
+ ...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]])),
126
+ stretch_ref: line.stretch_ref, started_at: line.started_at, at: line.ended_at, from_archive: true,
127
+ };
128
+ out.push({ entry, client: line.client ?? "claude" });
129
+ }
130
+ }
131
+ return out;
132
+ }
@@ -32,8 +32,23 @@ export const callKindsOf = (block) => {
32
32
  const command = block && SHELL_TOOLS.has(String(block.name)) ? block.input?.command ?? block.input?.CommandLine ?? block.input?.cmd : null;
33
33
  return typeof command === "string" ? CALL_KINDS.filter(([, pattern]) => pattern.test(command)).map(([kind]) => kind) : [];
34
34
  };
35
+ /**
36
+ * A TOOL RESULT AS THE STRETCH READS IT (0.8.1): its call's id, whether it failed, and, read here and never kept, WHO
37
+ * refused it when it was refused: the PERSON (Claude Code's fixed sentence when they decline a tool use or a plan) or a
38
+ * GUARD (the client's own safety layer or auto-mode classifier). Framework L8 and L5.
39
+ */
40
+ const PERSON_REFUSED = /^\s*(The user doesn't want to (proceed|take this action)|User rejected|The user rejected|The user declined)/i;
41
+ const GUARD_REFUSED = /^\s*Permission for this (command|action) was denied by/i;
42
+ export const resultOf = (block) => {
43
+ const text = typeof block.content === "string" ? block.content : Array.isArray(block.content) ? block.content.map((part) => (part && typeof part.text === "string" ? part.text : "")).join("") : "";
44
+ const failed = block.is_error === true;
45
+ return { id: block.tool_use_id, failed, refusal: failed && PERSON_REFUSED.test(text) ? "person" : failed && GUARD_REFUSED.test(text) ? "guard" : null };
46
+ };
47
+ /** A to-do list the agent wrote (TodoWrite): how many items and how many done, never what they say. */
48
+ const planOf = (block) => (block.name === "TodoWrite" && Array.isArray(block.input?.todos) ? { items: block.input.todos.length, done: block.input.todos.filter((todo) => todo && todo.status === "completed").length } : null);
49
+
35
50
  /** One tool call as the stretch reads it: its id, its kinds, its FAMILY (the tool and its check kinds) and a digest of its input (compared, never kept). */
36
- export const callOf = (block) => { const kinds = callKindsOf(block); return { id: block.id, kinds, family: `${String(block.name)}|${kinds.join("+")}`, digest: createHash("sha256").update(JSON.stringify(block.input ?? null)).digest("base64url").slice(0, 16) }; };
51
+ export const callOf = (block) => { const kinds = callKindsOf(block), plan = planOf(block); return { id: block.id, kinds, family: `${String(block.name)}|${kinds.join("+")}`, digest: createHash("sha256").update(JSON.stringify(block.input ?? null)).digest("base64url").slice(0, 16), ...(plan ? { plan } : {}) }; };
37
52
 
38
53
  /**
39
54
  * VERIFICATION PER STRETCH (0.7.1; framework L7, L12): per kind of check, how many ran and how many failed; per step of
@@ -194,9 +209,32 @@ export function complexityOf(derived, { layers = 0, agentRuns = 0, agentPeak = 0
194
209
  /** How many different tools the stretch's agent used (framework L11: tool breadth); null without a tool call. */
195
210
  const toolsOf = (messages) => { const names = new Set(); for (const message of messages) for (const call of message.calls ?? []) names.add(call.family.split("|")[0]); return names.size > 0 ? names.size : null; };
196
211
 
212
+ /**
213
+ * OVERSIGHT AND PLANNING PER STRETCH (0.8.1; framework L8, L5, L2). Oversight: how often the PERSON refused an action
214
+ * or a plan the agent proposed, and how often a GUARD (the client's safety layer) refused one. Planning: how often the
215
+ * agent wrote its to-do list, the most items one list held, and of the last list how many were done. Null where none.
216
+ */
217
+ function oversightOf(messages) {
218
+ let person = 0, plans = 0, guard = 0;
219
+ const tools = new Map();
220
+ for (const message of messages) for (const call of message.calls ?? []) tools.set(call.id, call.family.split("|")[0]);
221
+ for (const message of messages) for (const result of message.results ?? []) {
222
+ if (result.refusal === "person") { if (tools.get(result.id) === "ExitPlanMode") plans += 1; else person += 1; }
223
+ if (result.refusal === "guard") guard += 1;
224
+ }
225
+ return person + plans + guard > 0 ? { refused: person, plans_rejected: plans, guard_denied: guard } : null;
226
+ }
227
+ function planningOf(messages) {
228
+ const lists = [];
229
+ for (const message of messages) for (const call of message.calls ?? []) if (call.plan) lists.push(call.plan);
230
+ if (lists.length === 0) return null;
231
+ const last = lists.at(-1);
232
+ return { lists: lists.length, items_max: Math.max(...lists.map((list) => list.items)), items_last: last.items, done_last: last.done };
233
+ }
234
+
197
235
  /** The stretch's whole derived record; `helpers` are the hook's own readers of a human turn, so both read it the same way. */
198
236
  export function deriveStretch(messages, helpers) {
199
237
  const context = contextOf(messages, helpers), routing = routingOf(messages), steering = steeringOf(messages, helpers);
200
- const tools = toolsOf(messages);
201
- return { ...verificationOf(messages, helpers), ...(context ? { context } : {}), ...(routing ? { routing } : {}), ...(steering ? { steering } : {}), ...(tools ? { tools } : {}) };
238
+ const tools = toolsOf(messages), oversight = oversightOf(messages), planning = planningOf(messages);
239
+ return { ...(oversight ? { oversight } : {}), ...(planning ? { planning } : {}), ...verificationOf(messages, helpers), ...(context ? { context } : {}), ...(routing ? { routing } : {}), ...(steering ? { steering } : {}), ...(tools ? { tools } : {}) };
202
240
  }
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.1";
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");