worktrust 0.8.4 → 0.8.6

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
@@ -157,7 +157,7 @@ nothing it does not:
157
157
  | `commits` | the hashes of commits made in the stretch, never a message: what ties AI time to work in the repository |
158
158
  | `steered_from` | `remote` when the session ran over SSH |
159
159
  | `duration_basis`, `token_basis`, `turn_basis` | how the seconds, tokens and turns were measured |
160
- | `project` | a one-way hash of the project's name: which days belong together, never which project |
160
+ | `project` | a one-way hash of the project's name, keyed with this computer's own secret (`~/.worktrust/archive-salt`, never in the archive) so a guessed name cannot be tested: which days belong together, never which project |
161
161
  | `stretch_ref` | a hash of the session's id and the day, so a stretch is archived once |
162
162
  | `device_id`, `profile_id` | which computer and which user profile on it, as one-way hashes (the machine's own id never appears) |
163
163
  | `signals`, `analyzer_version` | the behaviour signals of that stretch as keys and counts from the local counter's rubric (framing, steering, verification, recovery), and the rubric's version; derived on this computer from your own turns, never a word of them (0.7.0) |
@@ -170,6 +170,7 @@ nothing it does not:
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
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
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) |
173
+ | `unconfirmed` | checks the stretch ran whose outcome no reader could see (Codex's code mode prints no exit code; a background terminal), per kind: counted, never as a pass (0.8.5) |
173
174
  | `collector_version` | the CLI version that measured it |
174
175
  | `seq`, `prev`, `hash` | the line's place, the hash of the line before it, and its own hash |
175
176
 
@@ -258,7 +259,7 @@ which would delete every password on the computer, and many people rightly keep
258
259
  | **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. |
259
260
  | **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/`. |
260
261
  | **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). |
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). |
262
+ | **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`, only for a repository on GitHub, GitLab or Bitbucket, the hosts WorkTrust can couple; never one on your own or a client's server) and the hashes of the commits made in the stretch (`commits`, never their messages). |
262
263
  | **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. |
263
264
 
264
265
  ## Why you can check it
package/log-session.mjs CHANGED
@@ -303,9 +303,15 @@ const layersOf = (paths) => {
303
303
  };
304
304
 
305
305
  const git = (cwd, ...argv) => { try { return execFileSync("git", ["-C", cwd, ...argv], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { return ""; } };
306
- /** owner/name from any remote shape, or null. Nothing else of the remote is read. */
306
+ /**
307
+ * owner/name of a remote WorkTrust can couple, or null (0.8.6, privacy by design). Only a repository on GitHub, GitLab or
308
+ * Bitbucket can be joined to its commits, CI and deploys; the name of one on the person's own or a client's server
309
+ * proves nothing and could name the client, so it is never sent. Nothing else of the remote is read.
310
+ */
311
+ const CODE_HOSTS = /^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?(github\.com|gitlab\.com|bitbucket\.org)[:/]/i;
307
312
  const repoOf = (cwd) => {
308
313
  const remote = git(cwd, "remote", "get-url", "origin");
314
+ if (!CODE_HOSTS.test(remote)) return null;
309
315
  const match = /[:/]([^/:]+)\/([^/]+?)(?:\.git)?$/.exec(remote);
310
316
  return match ? `${match[1]}/${match[2]}` : null;
311
317
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worktrust",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
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": {
@@ -4,7 +4,7 @@
4
4
  * tranches (docs/CAPABILITY-EVIDENCE-ROADMAP.md) add their fields here, each with its own check.
5
5
  */
6
6
  import { spawnSync } from "node:child_process";
7
- import { createHash } from "node:crypto";
7
+ import { createHash, createHmac } from "node:crypto";
8
8
  import { existsSync, readFileSync, readdirSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
 
@@ -32,7 +32,7 @@ export function stretchSignals(here, say) {
32
32
  return new Map(rows.slice(0, -1).filter(Boolean).map((row) => [row.stretch_ref, row]));
33
33
  }
34
34
  const SIGNAL = /^[a-zA-Z][A-Za-z0-9._-]{1,63}$/;
35
- export function archiveLine(entry, behaviour = null, { ids = {}, collector } = {}) {
35
+ export function archiveLine(entry, behaviour = null, { ids = {}, collector, salt = null } = {}) {
36
36
  if (typeof entry?.client !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(entry.client) || !ISO.test(entry.started_at ?? "") || !ISO.test(entry.at ?? "") || !/^[A-Za-z0-9_-]{16,128}$/.test(entry.stretch_ref ?? "")) return null;
37
37
  const line = { client: entry.client, day: entry.started_at.slice(0, 10), started_at: entry.started_at, ended_at: entry.at };
38
38
  for (const key of COUNTS) if (typeof entry[key] === "number" && Number.isFinite(entry[key]) && entry[key] >= 0) line[key] = entry[key];
@@ -46,7 +46,9 @@ export function archiveLine(entry, behaviour = null, { ids = {}, collector } = {
46
46
  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);
47
47
  // 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.
48
48
  const name = entry.repo ?? entry.folder;
49
- if (typeof name === "string" && name) line.project = sha256(`worktrust-project|${name}`);
49
+ // KEYED (0.8.6, audit S6): with this computer's own secret (~/.worktrust/archive-salt, never in the archive) a guessed
50
+ // repository name can no longer be tested against the archive; without one the plain hash, as before.
51
+ if (typeof name === "string" && name) line.project = salt ? createHmac("sha256", salt).update(`worktrust-project|${name}`).digest("hex") : sha256(`worktrust-project|${name}`);
50
52
  // VERIFICATION AND DELIVERY (0.7.1): per kind of check [ran, failed], per delivery step a count, and whether a check had
51
53
  // passed before the first; known kinds and whole numbers only, a failure count never above its runs.
52
54
  const whole = (value) => Number.isInteger(value) && value >= 0 && value <= 100000;
@@ -54,6 +56,11 @@ export function archiveLine(entry, behaviour = null, { ids = {}, collector } = {
54
56
  const kept = Object.entries(entry.verification).filter(([kind, tally]) => CHECK_KINDS.includes(kind) && Array.isArray(tally) && tally.length === 2 && tally.every(whole) && tally[1] <= tally[0] && tally[0] > 0).sort();
55
57
  if (kept.length > 0) line.verification = Object.fromEntries(kept);
56
58
  }
59
+ // UNCONFIRMED (0.8.5): checks run whose outcome no reader could see, per kind, whole counts.
60
+ if (entry.unconfirmed && typeof entry.unconfirmed === "object") {
61
+ const kept = Object.entries(entry.unconfirmed).filter(([kind, count]) => CHECK_KINDS.includes(kind) && whole(count) && count > 0).sort();
62
+ if (kept.length > 0) line.unconfirmed = Object.fromEntries(kept);
63
+ }
57
64
  if (entry.delivery && typeof entry.delivery === "object") {
58
65
  const kept = Object.entries(entry.delivery).filter(([kind, count]) => DELIVERY_KINDS.includes(kind) && whole(count) && count > 0).sort();
59
66
  if (kept.length > 0) line.delivery = { ...Object.fromEntries(kept), verified_first: entry.delivery.verified_first === true };
@@ -138,7 +145,7 @@ export function archiveEntries(dir, ownKey) {
138
145
  * a line of their own that names the stretch it adds to (`supplements`) and carries only what no earlier line of that
139
146
  * stretch carries, in the same chain, under the same day's root. Null when there is nothing new.
140
147
  */
141
- export const DERIVED_KEYS = ["signals", "analyzer_version", "verification", "delivery", "recovery", "delegation", "context", "routing", "steering", "tools", "complexity", "oversight", "planning", "changes", "interrupts", "steers", "utc_offset"];
148
+ export const DERIVED_KEYS = ["signals", "analyzer_version", "verification", "unconfirmed", "delivery", "recovery", "delegation", "context", "routing", "steering", "tools", "complexity", "oversight", "planning", "changes", "interrupts", "steers", "utc_offset"];
142
149
  export function supplementFor(line, earlier) {
143
150
  const missing = DERIVED_KEYS.filter((key) => line[key] !== undefined && !earlier.some((old) => old[key] !== undefined));
144
151
  if (missing.length === 0) return null;
package/preserve.mjs CHANGED
@@ -24,7 +24,7 @@
24
24
  * (~/.worktrust/key.json) when there is one. Today waits for the next run. Nothing leaves this computer.
25
25
  */
26
26
  import { execFileSync, spawnSync } from "node:child_process";
27
- import { createHash, createPrivateKey, createPublicKey, sign, verify } from "node:crypto";
27
+ import { createHash, createPrivateKey, createPublicKey, randomBytes, sign, verify } from "node:crypto";
28
28
  import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
29
29
  import { homedir, platform } from "node:os";
30
30
  import { dirname, join, relative } from "node:path";
@@ -182,6 +182,12 @@ function computerIds() {
182
182
  const hash = (text) => createHash("sha256").update(text).digest("base64url");
183
183
  return { device_id: hash(`worktrust-machine|${machine.toLowerCase()}`), profile_id: hash(`worktrust-profile|${machine.toLowerCase()}|${homedir()}`) };
184
184
  }
185
+ /** This computer's own secret for the archive's project hashes (0.8.6): made once, readable by its owner alone, never archived. */
186
+ function archiveSalt() {
187
+ const file = home(".worktrust", "archive-salt");
188
+ try { const kept = readFileSync(file, "utf8").trim(); if (/^[0-9a-f]{64}$/.test(kept)) return kept; } catch { /* first run */ }
189
+ try { mkdirSync(home(".worktrust"), { recursive: true, mode: 0o700 }); const made = randomBytes(32).toString("hex"); writeFileSync(file, `${made}\n`, { mode: 0o600, flag: "wx" }); return made; } catch { try { return readFileSync(file, "utf8").trim(); } catch { return null; } }
190
+ }
185
191
  /** The device key, when this computer is coupled: its private half signs, its public half goes into the proof. */
186
192
  function deviceKey() {
187
193
  try {
@@ -259,14 +265,14 @@ function archive(dir) {
259
265
  // A WHOLE ANSWER OR NOTHING: the hook ends with its own count; one line missing or cut is a read to refuse, never part of an archive.
260
266
  const rows = hook.stdout.split("\n").filter(Boolean), end = (() => { try { return JSON.parse(rows.at(-1) ?? "").archive_end; } catch { return undefined; } })();
261
267
  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
- const signals = stretchSignals(HERE, say);
268
+ const signals = stretchSignals(HERE, say), salt = archiveSalt();
263
269
  const known = new Set(existing.lines.map((line) => line.stretch_ref)), fresh = [], measured = new Set(), additions = [];
264
270
  const byRef = new Map(); // 0.8.2: every earlier line of a stretch, its supplements included
265
271
  for (const line of existing.lines) { const ref = line.supplements ?? line.stretch_ref; byRef.set(ref, [...(byRef.get(ref) ?? []), line]); }
266
272
  let waiting = 0;
267
273
  for (const text of rows.slice(0, -1)) {
268
274
  let entry; try { entry = JSON.parse(text); } catch { continue; }
269
- const line = archiveLine(entry, signals?.get(entry.stretch_ref) ?? null, { ids: IDS, collector: COLLECTOR });
275
+ const line = archiveLine(entry, signals?.get(entry.stretch_ref) ?? null, { ids: IDS, collector: COLLECTOR, salt });
270
276
  if (line && line.day < today()) measured.add(line.stretch_ref);
271
277
  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
278
  if (!line || known.has(line.stretch_ref)) continue; // a file present twice is one stretch
@@ -12,7 +12,7 @@ import { readFileSync, readdirSync, statSync } from "node:fs";
12
12
  import * as zlib from "node:zlib"; // zstd since Node 22.15; older Node reads OpenClaw's uncompressed rows only
13
13
  import { homedir, platform } from "node:os";
14
14
  import { join } from "node:path";
15
- import { cleanModel } from "./transcript-readers.mjs";
15
+ import { cleanModel, cursorCall, copilotCall } from "./transcript-readers.mjs";
16
16
 
17
17
  // ── hermes session reader ──
18
18
  /**
@@ -344,18 +344,34 @@ export function cursorLines(pseudo) {
344
344
  const chat = db.prepare("select json_extract(value, '$.modelConfig.modelName') as model from cursorDiskKV where key = ?").get(`composerData:${id}`);
345
345
  const bubbles = db.prepare(`select json_extract(value, '$.type') as type, json_extract(value, '$.createdAt') as at,
346
346
  json_extract(value, '$.tokenCount.inputTokens') as input, json_extract(value, '$.tokenCount.outputTokens') as output,
347
- json_extract(value, '$.toolFormerData') is not null as tool
347
+ json_extract(value, '$.toolFormerData') is not null as tool,
348
+ json_extract(value, '$.toolFormerData.name') as toolName, json_extract(value, '$.toolFormerData.toolCallId') as callId,
349
+ json_extract(value, '$.toolFormerData.status') as status, json_extract(value, '$.toolFormerData.userDecision') as decision,
350
+ json_extract(value, '$.toolFormerData.additionalData.status') as outcome,
351
+ json_extract(json_extract(value, '$.toolFormerData.params'), '$.command') as command,
352
+ json_extract(json_extract(value, '$.toolFormerData.result'), '$.exitCode') as exitCode,
353
+ json_extract(json_extract(value, '$.toolFormerData.result'), '$.rejected') as rejected,
354
+ json_extract(json_extract(value, '$.toolFormerData.result'), '$.backgroundShellId') is not null as background,
355
+ json_type(json_extract(value, '$.toolFormerData.result'), '$.output') = 'text' as hasOutput,
356
+ (select json_group_array(json_extract(todo.value, '$.status')) from json_each(json_extract(cursorDiskKV.value, '$.toolFormerData.result'), '$.finalTodos') as todo) as todoStatuses
348
357
  from cursorDiskKV where key > ? and key < ? and json_extract(value, '$.createdAt') is not null`).all(`bubbleId:${id}:`, `bubbleId:${id};`);
349
358
  const model = cleanModel(chat?.model);
350
359
  const lines = [];
360
+ let pending = null;
351
361
  for (const bubble of bubbles.sort((a, b) => String(a.at).localeCompare(String(b.at)))) {
352
362
  const timestamp = isoOf(bubble.at); if (!timestamp) continue;
363
+ if (pending) { lines.push({ ...pending, timestamp }); pending = null; }
353
364
  if (Number(bubble.type) === 1) lines.push({ type: "user", timestamp, message: { content: [{ type: "text" }] } });
354
365
  else if (Number(bubble.type) === 2) {
355
366
  const usage = Number(bubble.input) || Number(bubble.output) ? { usage: { input_tokens: Number(bubble.input) || 0, output_tokens: Number(bubble.output) || 0 } } : {};
356
- lines.push({ type: "assistant", timestamp, message: { model, ...usage, content: bubble.tool ? [{ type: "tool_use", name: "tool", input: {} }] : [] } });
367
+ const call = bubble.tool ? cursorCall(bubble) : null;
368
+ lines.push({ type: "assistant", timestamp, message: { model, ...usage, content: call ? [call.use] : [] } });
369
+ // THE CALL'S RESULT (0.8.5) lands at the NEXT bubble's moment: the gap after a call stays tool time and no gap is
370
+ // added, so the stretch's seconds and layers are exactly as before; a call that ends the session closes on its own moment.
371
+ if (call) pending = { type: "user", timestamp, message: { content: [call.result] } };
357
372
  }
358
373
  }
374
+ if (pending) lines.push(pending);
359
375
  return lines;
360
376
  } catch { return null; } finally { db.close(); }
361
377
  }
@@ -421,6 +437,13 @@ export function copilotLines(pseudo) {
421
437
  const meta = request?.result?.metadata ?? {};
422
438
  const usage = Number(meta.promptTokens) || Number(meta.outputTokens) ? { usage: { input_tokens: Number(meta.promptTokens) || 0, output_tokens: Number(meta.outputTokens) || 0 } } : {};
423
439
  // `agentic` (owner, 2026-10-06): the request's own run, asked → completed, is the agent's time; the hook files that gap as tool time, uncapped.
440
+ // THE REQUEST'S TOOL CALLS (0.8.5), all at the moment it was asked: no gap is added, so its seconds and layers are as before.
441
+ for (const part of Array.isArray(request?.response) ? request.response : []) {
442
+ const call = part && part.kind === "toolInvocationSerialized" ? copilotCall(part) : null;
443
+ if (!call) continue;
444
+ lines.push({ type: "assistant", timestamp: new Date(asked).toISOString(), message: { content: [call.use] } });
445
+ lines.push({ type: "user", timestamp: new Date(asked).toISOString(), message: { content: [call.result] } });
446
+ }
424
447
  lines.push({ type: "assistant", timestamp: new Date(done).toISOString(), agentic: true, message: { model: cleanModel(String(meta.resolvedModel ?? request?.modelId ?? "").replace(/^copilot\//, "")), ...usage, content: [] } });
425
448
  }
426
449
  return lines;
@@ -42,6 +42,8 @@ const GUARD_REFUSED = /^\s*Permission for this (command|action) was denied by/i;
42
42
  export const resultOf = (block) => {
43
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
44
  const failed = block.is_error === true;
45
+ // 0.8.5: a reader that could not see the outcome (Codex's code mode, a background terminal) says so; unknown is never a pass.
46
+ if (!failed && block.outcome === "unknown") return { id: block.tool_use_id, failed: null, refusal: null };
45
47
  return { id: block.tool_use_id, failed, refusal: failed && PERSON_REFUSED.test(text) ? "person" : failed && GUARD_REFUSED.test(text) ? "guard" : null };
46
48
  };
47
49
  /** A to-do list the agent wrote (TodoWrite): how many items and how many done, never what they say. */
@@ -58,17 +60,20 @@ export const callOf = (block) => { const kinds = callKindsOf(block), plan = plan
58
60
  function verificationOf(messages, helpers) {
59
61
  const kinds = new Map();
60
62
  for (const message of messages) for (const call of message.calls ?? []) kinds.set(call.id, call.kinds);
61
- const checks = {}, delivered = {};
63
+ const checks = {}, delivered = {}, unconfirmed = {};
62
64
  let deliveredAt = null, passedFirst = false;
63
65
  for (const message of messages) for (const result of message.results ?? []) {
64
66
  for (const kind of kinds.get(result.id) ?? []) {
67
+ // A check whose outcome nobody could see is counted as run and unconfirmed, apart from the known tallies (0.8.5).
68
+ if (CHECK_KINDS.includes(kind) && result.failed === null) { unconfirmed[kind] = (unconfirmed[kind] ?? 0) + 1; continue; }
69
+ if (DELIVERY_KINDS.includes(kind) && result.failed === null) continue;
65
70
  if (CHECK_KINDS.includes(kind)) { const tally = (checks[kind] ??= [0, 0]); tally[0] += 1; if (result.failed) tally[1] += 1; else if (deliveredAt === null) passedFirst = true; }
66
71
  else if (DELIVERY_KINDS.includes(kind) && !result.failed) { delivered[kind] = (delivered[kind] ?? 0) + 1; if (deliveredAt === null) deliveredAt = message.at; }
67
72
  }
68
73
  }
69
74
  const any = (record) => Object.keys(record).length > 0;
70
75
  const recovery = recoveryOf(messages), delegation = delegationOf(messages, helpers);
71
- return { ...(any(checks) ? { verification: checks } : {}), ...(any(delivered) ? { delivery: { ...delivered, verified_first: passedFirst } } : {}), ...(recovery ? { recovery } : {}), ...(delegation ? { delegation } : {}) };
76
+ return { ...(any(checks) ? { verification: checks } : {}), ...(any(unconfirmed) ? { unconfirmed } : {}), ...(any(delivered) ? { delivery: { ...delivered, verified_first: passedFirst } } : {}), ...(recovery ? { recovery } : {}), ...(delegation ? { delegation } : {}) };
72
77
  }
73
78
 
74
79
  /**
@@ -43,7 +43,7 @@ export const CODEX_ROLLOUT = /(^|[\\/])rollout-\d{4}-\d{2}-\d{2}T[\d-]+-[0-9a-f-
43
43
  /** A rollout's thread id: its file's name without .jsonl, split on either separator. */
44
44
  export const codexIdOf = (file) => String(file).split(/[\\/]/).at(-1).replace(/\.jsonl$/, "");
45
45
  export const CODEX_HARNESS_TURN = /^\s*<[a-z][a-z_]*[\s>]/i;
46
- export const codexToolName = (name) => (name === "exec" || name === "shell" || name === "container.exec" || name === "local_shell" ? "Bash" : name === "apply_patch" ? "Edit" : name === "spawn_agent" ? "Agent" : String(name ?? "tool"));
46
+ export const codexToolName = (name) => (name === "exec" || name === "shell" || name === "container.exec" || name === "local_shell" ? "Bash" : name === "apply_patch" ? "Edit" : name === "spawn_agent" || name === "followup_task" ? "Agent" : name === "request_user_input" || name === "request_user_input_async" ? "AskUserQuestion" : String(name ?? "tool"));
47
47
  export const codexToolInput = (name, raw) => {
48
48
  const mapped = codexToolName(name);
49
49
  if (mapped === "Bash") {
@@ -91,7 +91,7 @@ export function* codexLines(records) {
91
91
  if (kind === "custom_tool_call_output" || kind === "function_call_output") {
92
92
  const text = codexOutputText(payload.output);
93
93
  const exit = /(?:failed with|exited with|exit code)[:\s]+(-?\d+)/i.exec(text.slice(-400));
94
- yield { type: "user", timestamp, cwd, uuid: payload.id ? `codex-${payload.id}` : undefined, message: { content: [{ type: "tool_result", ...(payload.call_id ? { tool_use_id: String(payload.call_id) } : {}), content: text, is_error: exit ? exit[1] !== "0" : false }] } };
94
+ yield { type: "user", timestamp, cwd, uuid: payload.id ? `codex-${payload.id}` : undefined, message: { content: [{ type: "tool_result", ...(payload.call_id ? { tool_use_id: String(payload.call_id) } : {}), content: text, is_error: exit ? exit[1] !== "0" : false, ...(exit ? {} : { outcome: "unknown" }) }] } }; // 0.8.5: no exit code (Codex code mode never prints one) is an unknown outcome, never a pass
95
95
  }
96
96
  }
97
97
  }
@@ -220,3 +220,44 @@ export function rememberAntigravity(input) {
220
220
  return id;
221
221
  }
222
222
  // ── end antigravity transcript reader ──
223
+
224
+ // ── the database clients' tool calls (0.8.5), read by session-databases.mjs ──
225
+ /**
226
+ * A CURSOR TOOL CALL AS THE STRETCH READS IT (0.8.5): its name mapped to the common one (a terminal command is Bash,
227
+ * its to-do list is TodoWrite), the command for the CLI to name a KIND of (never kept), the to-do statuses (never their
228
+ * text), and the outcome Cursor recorded: an exit code only when the command failed, an error status, or the person's
229
+ * rejection; a background command, or a call with no outcome, is unknown, never a pass.
230
+ */
231
+ const CURSOR_NAMES = { run_terminal_command_v2: "Bash", run_terminal_cmd: "Bash", todo_write: "TodoWrite", edit_file_v2: "Edit", edit_file: "Edit", read_file_v2: "Read", read_file: "Read", ripgrep_raw_search: "Grep", glob_file_search: "Glob", delete_file: "Edit" };
232
+ const json = (text) => { if (typeof text !== "string") return null; try { return JSON.parse(text); } catch { return null; } };
233
+ export function cursorCall(bubble) {
234
+ const id = typeof bubble.callId === "string" && bubble.callId ? bubble.callId : null;
235
+ if (!id) return null;
236
+ const raw = String(bubble.toolName ?? "tool"), name = CURSOR_NAMES[raw] ?? raw;
237
+ const todos = json(bubble.todoStatuses);
238
+ const input = name === "Bash" ? { command: typeof bubble.command === "string" ? bubble.command : "" } : name === "TodoWrite" && Array.isArray(todos) && todos.length > 0 ? { todos: todos.map((status) => ({ status: status === "completed" ? "completed" : "pending" })) } : {};
239
+ const rejected = Number(bubble.rejected) === 1 || bubble.rejected === true || bubble.decision === "rejected";
240
+ const exit = bubble.exitCode === null || bubble.exitCode === undefined ? null : Number(bubble.exitCode);
241
+ const failed = rejected || (exit !== null && exit !== 0) || bubble.outcome === "error" || bubble.status === "error";
242
+ const known = failed || (name === "Bash" ? Number(bubble.hasOutput) === 1 && Number(bubble.background) !== 1 : bubble.status === "completed" || bubble.outcome === "success");
243
+ return {
244
+ use: { type: "tool_use", id, name, input },
245
+ result: { type: "tool_result", tool_use_id: id, is_error: failed, ...(known ? {} : { outcome: "unknown" }), content: rejected ? "User rejected the tool call." : "" },
246
+ };
247
+ }
248
+ /**
249
+ * A COPILOT TOOL CALL AS THE STRETCH READS IT (0.8.5): a terminal command is Bash (its command line for the CLI to name
250
+ * a kind of, never kept) with the exit code VS Code recorded; a missing exit code is unknown. Its to-do list is TodoWrite
251
+ * (statuses only), its subagents are Agent. What a confirmation type means is not documented, so no refusal is read.
252
+ */
253
+ const COPILOT_NAMES = { run_in_terminal: "Bash", manage_todo_list: "TodoWrite", execution_subagent: "Agent", runSubagent: "Agent", copilot_replaceString: "Edit", copilot_createFile: "Write", copilot_readFile: "Read", copilot_findTextInFiles: "Grep", copilot_findFiles: "Glob" };
254
+ export function copilotCall(part) {
255
+ const id = typeof part.toolCallId === "string" && part.toolCallId ? part.toolCallId : null;
256
+ if (!id) return null;
257
+ const raw = String(part.toolId ?? "tool"), name = COPILOT_NAMES[raw] ?? raw, data = part.toolSpecificData ?? {};
258
+ const line = data.commandLine && typeof data.commandLine === "object" ? data.commandLine.toolEdited ?? data.commandLine.original : null;
259
+ const input = name === "Bash" ? { command: typeof line === "string" ? line : "" } : name === "TodoWrite" && Array.isArray(data.todoList) ? { todos: data.todoList.map((todo) => ({ status: todo?.status === "completed" ? "completed" : "pending" })) } : {};
260
+ const exit = Number.isInteger(data.terminalCommandState?.exitCode) ? data.terminalCommandState.exitCode : null;
261
+ const known = name === "Bash" ? exit !== null : false;
262
+ return { use: { type: "tool_use", id, name, input }, result: { type: "tool_result", tool_use_id: id, is_error: exit !== null && exit !== 0, ...(known ? {} : { outcome: "unknown" }), content: "" } };
263
+ }
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.4";
76
+ const CLI_VERSION = "0.8.6";
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");