worktrust 0.8.1 → 0.8.4

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
@@ -168,6 +168,8 @@ nothing it does not:
168
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) |
169
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
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) |
171
+ | `supplements` | a supplement line: when a newer CLI measures more of a stretch already archived, the new fields go into a line of their own that names that stretch; nothing earlier is rewritten, and the chain and the day's root cover it (0.8.2) |
172
+ | `evidence` (sent) | the same derived record now also travels with each stretch to WorkTrust, kinds and counts only, and `npx worktrust status` shows what this computer measured in the last thirty days (0.8.3) |
171
173
  | `collector_version` | the CLI version that measured it |
172
174
  | `seq`, `prev`, `hash` | the line's place, the hash of the line before it, and its own hash |
173
175
 
@@ -256,7 +258,7 @@ which would delete every password on the computer, and many people rightly keep
256
258
  | **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. |
257
259
  | **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/`. |
258
260
  | **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). |
259
- | **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). |
261
+ | **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`), what the CLI derived from the stretch as kinds and counts (`evidence`: checks run and failed, delivery steps and whether a check passed first, recoveries, delegation, context, routing, steering, refusals, planning, files and test files changed, the complexity class; never a word, a command or a path; since 0.8.3), 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). |
260
262
  | **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. |
261
263
 
262
264
  ## Why you can check it
package/log-session.mjs CHANGED
@@ -593,6 +593,10 @@ function payloadFor(stretch) {
593
593
  // The seventh kind, used as it is defined: a stretch that changed no artefact is knowing
594
594
  // something, not building something, and it earns neither an artefact's nor a validation's credit.
595
595
  const kind = touched.paths.length === 0 ? "researched" : touched.committed ? "changed" : "built";
596
+ // 0.8.1: what the stretch changed, as counts of files (never their paths): how many, and how many of them tests.
597
+ // 0.7.6: and the stretch's complexity class, read from what the hook knows here (layers, subagents, duration) and the record.
598
+ const changed = [...new Set(touched.paths)], tests = changed.filter((path) => TEST_PATH.test(path)).length;
599
+ const derived = stretch.derived ? { ...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 }) } : {}) } : null;
596
600
  const payload = {
597
601
  title: layer ? `AI-assisted work · ${layer}` : "AI-assisted work",
598
602
  kind,
@@ -622,13 +626,13 @@ function payloadFor(stretch) {
622
626
  ...(stretch.interrupts > 0 ? { interrupts: Math.min(10000, stretch.interrupts) } : {}),
623
627
  ...(stretch.steers > 0 ? { steers: Math.min(10000, stretch.steers) } : {}),
624
628
  ...(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) } : {}),
629
+ // THE STRETCH'S EVIDENCE (0.8.3): what the CLI derived from it here, kinds and counts only (stretch-evidence.mjs); the door keeps it from a CLI key alone.
630
+ ...(derived ? { evidence: derived } : {}),
625
631
  at: new Date(stretch.to).toISOString(),
626
632
  };
627
633
  // WHAT STAYS ON THIS COMPUTER (0.7.1): the stretch's derived record rides beside the payload for the local archive, never in it.
628
- // 0.7.6: and the stretch's complexity class, read from what the hook knows here (layers, subagents, duration) and the record.
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 }) } : {}) });
634
+ // The same record rides beside the payload for the local archive (preserve --archive).
635
+ if (derived) DERIVED.set(payload, derived);
632
636
  return payload;
633
637
  }
634
638
 
@@ -934,6 +938,24 @@ const doorFor = (entry) => (String(entry?.id ?? "").startsWith("codex:") ? codex
934
938
  const entrySource = new WeakMap(); // an entry → "codex" or "antigravity" by the client that wrote it; the sends route and name by it
935
939
  const entryFile = new WeakMap(); // a history entry → its transcript (`--summary` counts sessions), first moment and folder name (`--archive-lines`)
936
940
  // THE LOCAL ARCHIVE (`npx worktrust preserve --archive`, 2026-10-06): every measured day, live window and floors aside, as JSON lines to preserve.mjs on this computer, which keeps metadata only; nothing is sent.
941
+ // WHAT THIS COMPUTER MEASURES (0.8.3, for `npx worktrust status`): the last thirty days of its own sessions, summed from
942
+ // the same derived record the hook sends, counts only; reads, prints and exits, nothing sent.
943
+ if (flag("evidence-summary")) {
944
+ const since = Date.now() - 30 * 86400_000, rows = historyEntries(null, true).filter((entry) => Date.parse(entry.at) >= since);
945
+ const sum = (pick) => rows.reduce((total, entry) => total + (pick(DERIVED.get(entry) ?? {}) ?? 0), 0);
946
+ const complexity = {};
947
+ for (const entry of rows) { const tier = DERIVED.get(entry)?.complexity?.class; if (tier) complexity[tier] = (complexity[tier] ?? 0) + 1; }
948
+ const summary = {
949
+ stretches: rows.length, hours: Math.round(rows.reduce((total, entry) => total + (entry.seconds ?? 0), 0) / 360) / 10,
950
+ delivered: sum((d) => (d.delivery ? 1 : 0)), verified_first: sum((d) => (d.delivery?.verified_first ? 1 : 0)),
951
+ failures: sum((d) => d.recovery?.failures), recovered: sum((d) => d.recovery?.recovered), blind_retries: sum((d) => d.recovery?.blind_retries),
952
+ steers: sum((d) => d.steering?.moments), effective_steers: sum((d) => d.steering?.effective),
953
+ refused: sum((d) => (d.oversight ? d.oversight.refused + d.oversight.plans_rejected : 0)), guard_denied: sum((d) => d.oversight?.guard_denied),
954
+ planned: sum((d) => (d.planning ? 1 : 0)), files: sum((d) => d.changes?.files), test_files: sum((d) => d.changes?.test_files), complexity,
955
+ };
956
+ process.stdout.write(`${JSON.stringify(summary)}\n`);
957
+ await exitFlushed(0);
958
+ }
937
959
  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
938
960
  if (!door && !codexDoor && !DRY) { process.exit(0); } // Not coupled on this machine: nothing to do, quietly.
939
961
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worktrust",
3
- "version": "0.8.1",
3
+ "version": "0.8.4",
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": {
@@ -116,6 +116,7 @@ export function archiveEntries(dir, ownKey) {
116
116
  if (!existsSync(file)) continue;
117
117
  for (const text of readFileSync(file, "utf8").split("\n").filter(Boolean)) {
118
118
  let line; try { line = JSON.parse(text); } catch { continue; }
119
+ if (line.supplements) continue; // a supplement adds fields to a stretch, it is no stretch of its own
119
120
  const proof = read(join(dir, "proofs", `${line.day}.json`));
120
121
  if (!ownKey || !proof || typeof proof.signature !== "string" || proof.public_key !== ownKey) continue;
121
122
  const entry = {
@@ -130,3 +131,17 @@ export function archiveEntries(dir, ownKey) {
130
131
  }
131
132
  return out;
132
133
  }
134
+
135
+ /**
136
+ * A SUPPLEMENT FOR A STRETCH ALREADY ARCHIVED (0.8.2). The archive only grows: a line is never rewritten. When a newer
137
+ * CLI measures more of a stretch that is already in it (verification, recovery, the signals …), the new fields go into
138
+ * a line of their own that names the stretch it adds to (`supplements`) and carries only what no earlier line of that
139
+ * stretch carries, in the same chain, under the same day's root. Null when there is nothing new.
140
+ */
141
+ export const DERIVED_KEYS = ["signals", "analyzer_version", "verification", "delivery", "recovery", "delegation", "context", "routing", "steering", "tools", "complexity", "oversight", "planning", "changes", "interrupts", "steers", "utc_offset"];
142
+ export function supplementFor(line, earlier) {
143
+ const missing = DERIVED_KEYS.filter((key) => line[key] !== undefined && !earlier.some((old) => old[key] !== undefined));
144
+ if (missing.length === 0) return null;
145
+ const { device_id: device, profile_id: profile } = line;
146
+ return { client: line.client, day: line.day, started_at: line.started_at, ended_at: line.ended_at, supplements: line.stretch_ref, ...Object.fromEntries(missing.map((key) => [key, line[key]])), ...(device ? { device_id: device } : {}), ...(profile ? { profile_id: profile } : {}), collector_version: line.collector_version };
147
+ }
package/preserve.mjs CHANGED
@@ -31,7 +31,7 @@ import { dirname, join, relative } from "node:path";
31
31
  import { createInterface } from "node:readline/promises";
32
32
  import { fileURLToPath } from "node:url";
33
33
  import { summaryLines } from "./preserve-summary.mjs";
34
- import { archiveLine, stretchSignals } from "./preserve-lines.mjs";
34
+ import { archiveLine, stretchSignals, supplementFor } from "./preserve-lines.mjs";
35
35
 
36
36
  const args = process.argv.slice(2).filter((arg) => arg !== "preserve");
37
37
  const has = (name) => args.includes(`--${name}`);
@@ -260,12 +260,16 @@ function archive(dir) {
260
260
  const rows = hook.stdout.split("\n").filter(Boolean), end = (() => { try { return JSON.parse(rows.at(-1) ?? "").archive_end; } catch { return undefined; } })();
261
261
  if (!Number.isInteger(end) || end !== rows.length - 1) { say(` The session hook's answer came back incomplete (${rows.length - 1} of ${end ?? "an unknown number of"} lines), so nothing was added. Run it again.`); process.exit(1); }
262
262
  const signals = stretchSignals(HERE, say);
263
- const known = new Set(existing.lines.map((line) => line.stretch_ref)), fresh = [], measured = new Set();
263
+ const known = new Set(existing.lines.map((line) => line.stretch_ref)), fresh = [], measured = new Set(), additions = [];
264
+ const byRef = new Map(); // 0.8.2: every earlier line of a stretch, its supplements included
265
+ for (const line of existing.lines) { const ref = line.supplements ?? line.stretch_ref; byRef.set(ref, [...(byRef.get(ref) ?? []), line]); }
264
266
  let waiting = 0;
265
267
  for (const text of rows.slice(0, -1)) {
266
268
  let entry; try { entry = JSON.parse(text); } catch { continue; }
267
269
  const line = archiveLine(entry, signals?.get(entry.stretch_ref) ?? null, { ids: IDS, collector: COLLECTOR });
268
- if (line && line.day < today()) measured.add(line.stretch_ref); if (!line || known.has(line.stretch_ref)) continue; // a file present twice is one stretch
270
+ if (line && line.day < today()) measured.add(line.stretch_ref);
271
+ if (line && byRef.has(line.stretch_ref)) { const extra = supplementFor(line, byRef.get(line.stretch_ref)); if (extra) { additions.push(extra); byRef.set(line.stretch_ref, [...byRef.get(line.stretch_ref), extra]); } continue; }
272
+ if (!line || known.has(line.stretch_ref)) continue; // a file present twice is one stretch
269
273
  if (line.day >= today()) { waiting += 1; continue; }
270
274
  known.add(line.stretch_ref);
271
275
  fresh.push(line);
@@ -274,7 +278,7 @@ function archive(dir) {
274
278
  mkdirSync(dir, { recursive: true, mode: 0o700 });
275
279
  let prev = existing.lines.at(-1)?.hash ?? null, seq = existing.lines.length;
276
280
  const all = [...existing.lines];
277
- for (const line of fresh) {
281
+ for (const line of [...fresh, ...additions]) {
278
282
  const chained = { ...line, seq: (seq += 1), prev };
279
283
  const hashed = { ...chained, hash: sha256(canonical(chained)) };
280
284
  mkdirSync(join(dir, line.day.slice(0, 4), line.day.slice(5, 7)), { recursive: true });
@@ -288,16 +292,18 @@ function archive(dir) {
288
292
  const key = deviceKey();
289
293
  const ours = (document) => !document || (key && typeof document.signature === "string" && document.public_key === key.publicX);
290
294
  mkdirSync(join(dir, "proofs"), { recursive: true });
291
- for (const day of new Set(fresh.map((line) => line.day))) {
295
+ for (const day of new Set([...fresh, ...additions].map((line) => line.day))) {
292
296
  const own = !existing.days?.has(day) || ours(existing.proofs?.get(day));
293
297
  const body = proofBody(day, all.filter((line) => line.day === day), own ? key : null);
294
298
  writeFileSync(join(dir, "proofs", `${day}.json`), `${JSON.stringify(sealed(body, own ? key : null), null, 1)}\n`);
295
299
  }
296
300
  // The manifest is sealed the same way: a new archive, or one whose manifest this key already signed.
297
301
  const manifestKey = !existing.manifest || ours(existing.manifest) ? key : null;
298
- 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`);
302
+ if (fresh.length + additions.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`);
299
303
  say();
300
- 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)"}.`);
304
+ const stretches = all.filter((line) => !line.supplements).length;
305
+ say(` ✓ ${fresh.length} new day-stretch${fresh.length === 1 ? "" : "es"} archived in ${dir}: it holds ${stretches}, 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)"}.`);
306
+ if (additions.length > 0) say(` ${additions.length} stretch${additions.length === 1 ? "" : "es"} already here gained the newer measurements, each in a supplement line; nothing earlier was rewritten.`);
301
307
  if (waiting > 0) say(` ${waiting} of today wait for tomorrow's run: a day is archived once it is over.`);
302
308
  say(" Metadata only: clocks, counts, model names, a hash of each project's name. Nothing was sent.");
303
309
  }
@@ -308,7 +314,8 @@ function verifyArchive(dir) {
308
314
  if (result.lines.length === 0) { say(` Nothing to verify in ${dir}.`); return; }
309
315
  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)`;
310
316
  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";
311
- say(` OK ${result.lines.length} stretches · ${result.days.size} days · the chain is whole · every day's root matches · ${daySeal} · ${seal}`);
317
+ const added = result.lines.filter((line) => line.supplements).length;
318
+ say(` OK ${result.lines.length - added} stretches${added ? ` (and ${added} supplement${added === 1 ? "" : "s"})` : ""} · ${result.days.size} days · the chain is whole · every day's root matches · ${daySeal} · ${seal}`);
312
319
  }
313
320
 
314
321
  /** --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
@@ -73,7 +73,7 @@ const command = args.find((arg, at) => !arg.startsWith("--") && !(at > 0 && VALU
73
73
  const flag = (name) => { const at = args.indexOf(`--${name}`); return at >= 0 ? args[at + 1] : undefined; };
74
74
  const has = (name) => args.includes(`--${name}`);
75
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). */
76
- const CLI_VERSION = "0.8.1";
76
+ const CLI_VERSION = "0.8.4";
77
77
  const ORIGIN = (flag("origin") ?? process.env.WORKTRUST_ORIGIN ?? "https://app.worktrust.io").replace(/\/$/, "");
78
78
  const MCP = flag("url") ?? process.env.WORKTRUST_MCP_URL ?? `${ORIGIN}/api/mcp`;
79
79
  const HOME_DIR = join(homedir(), ".worktrust");
@@ -844,7 +844,7 @@ async function disconnect() {
844
844
  say(" Done. Revoke the key in WorkTrust to finish.");
845
845
  }
846
846
 
847
- function status() {
847
+ async function status() {
848
848
  const servers = {};
849
849
  try {
850
850
  const config = JSON.parse(readFileSync(join(homedir(), ".claude.json"), "utf8"));
@@ -863,6 +863,33 @@ function status() {
863
863
  if (existsSync(join(homedir(), ".hermes"))) { let text = ""; try { text = readFileSync(join(homedir(), ".hermes", "config.yaml"), "utf8"); } catch { /* none */ } say(/worktrust\.mjs\\?" hook --hermes/.test(text) ? " Hermes Agent: wakes the session hook" : " Hermes Agent: does not wake the session hook (npx worktrust@latest update writes it)"); }
864
864
  if (antigravityHere()) say(agyHooks()?.worktrust ? " Antigravity: wakes the session hook" : " Antigravity: does not wake the session hook (npx worktrust@latest update asks)");
865
865
  say(existsSync(join(HOME_DIR, "pinned")) ? " Counter: from the package, pinned" : existsSync(join(HOME_DIR, "count-behaviour.mjs")) ? " Counter: follows the site" : " Counter: not installed");
866
+ await measuredHere();
867
+ }
868
+
869
+ /**
870
+ * WHAT THIS COMPUTER MEASURES (0.8.3): the last thirty days of its own sessions, as the hook derives them and sends them
871
+ * with each stretch (kinds and counts, never text). Read here by the hook beside this file; nothing is sent to show it.
872
+ */
873
+ async function measuredHere() {
874
+ const hook = [join(HERE, "log-session.mjs"), join(HOME_DIR, "log-session.mjs")].find((path) => existsSync(path));
875
+ if (!hook) return;
876
+ // Through the CLI's one way of starting a script (never a synchronous child: check-computer-pairing), with no door to send to.
877
+ const read = await run(hook, ["--evidence-summary"], "", true, { WORKTRUST_MCP_URL: "", WORKTRUST_HOOK_INPUT: "" });
878
+ let m = null;
879
+ try { m = JSON.parse(read.out.trim().split("\n").filter((text) => text.startsWith("{")).pop()); } catch { return; }
880
+ if (!m || !m.stretches) { say(" Measured here, last 30 days: no finished work stretch yet."); return; }
881
+ const of = (part, whole, unit) => `${part} of ${whole} ${unit}`;
882
+ say();
883
+ say(` Measured here, last 30 days: ${m.stretches} work stretches · ${m.hours} h`);
884
+ if (m.delivered) say(` verified before delivery ${of(m.verified_first, m.delivered, "stretches that delivered")}`);
885
+ if (m.failures) say(` recovered failures ${of(m.recovered, m.failures, "failed tool calls")} (${m.blind_retries} retried unchanged)`);
886
+ if (m.steers) say(` steering that worked ${of(m.effective_steers, m.steers, "times you stepped in")}`);
887
+ if (m.refused || m.guard_denied) say(` refused ${m.refused} by you · ${m.guard_denied} by a guard`);
888
+ if (m.planned) say(` planned ${m.planned} stretches with a to-do list`);
889
+ if (m.files) say(` tests among changed files ${of(m.test_files, m.files, "files")}`);
890
+ const tiers = Object.entries(m.complexity ?? {}).sort();
891
+ if (tiers.length) say(` task complexity ${tiers.map(([tier, n]) => `${tier} ${n}`).join(" · ")}`);
892
+ say(" Sent with each stretch as kinds and counts, never text. Keep your own copy: npx worktrust@latest preserve");
866
893
  }
867
894
 
868
895
  /** Claude Code's `cleanupPeriodDays` (its default 30), or null without Claude Code here. Read only: preserve.mjs changes it, on a yes. */
@@ -928,6 +955,6 @@ else if (command === "sources" || command === "web" || command === "import") { s
928
955
  else if (command === "codex") await offerCodex();
929
956
  else if (command === "antigravity") await offerAntigravity();
930
957
  else if (command === "disconnect") await disconnect();
931
- else if (command === "status") status();
958
+ else if (command === "status") await status();
932
959
  else if (command === "preserve") await preserve();
933
960
  else help();