worktrust 0.8.7 → 0.8.9

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
@@ -172,6 +172,8 @@ nothing it does not:
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
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) |
174
174
  | `autonomy`, `authorship` | the permission mode the AI app ran in, on one scale (ask, edits, plan, auto, full), how often it changed and the turns in plan mode; of the stretch's commits, how many name an AI as co-author (the trailer is read here, its names never leave) (0.8.7) |
175
+ | `framing`, `quality`, `risk`, `tool_mix`, `reads`, `context_files` | how the work was framed (your turns before the agent's first action, a plan first or not), checked (a check passing after the last change, the failures before a check passed, the change looked at before delivery; security scans count as checks), risked (destructive commands proposed, refused, run), which kinds of tools were used, files read again unchanged, and which of the AI app's own context files (instructions, skills, agents, commands, settings, MCP) the commits changed; kinds and counts only (0.8.8) |
176
+ | `quality.change_blocks`, `checked_blocks`, `rework_cycles`, `stage` | how many change blocks (edits with no check between them) a check followed, how often a failed check was followed by another change before it ran again (rework), and the furthest step the stretch reached here: attempted, verified, committed, pushed, pr, deployed; counts and one word only (0.8.9) |
175
177
  | `collector_version` | the CLI version that measured it |
176
178
  | `seq`, `prev`, `hash` | the line's place, the hash of the line before it, and its own hash |
177
179
 
package/log-session.mjs CHANGED
@@ -135,6 +135,20 @@ 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
+ /**
139
+ * CONTEXT ENGINEERING (0.8.8, framework L6): which of an AI app's own context files the stretch changed, by category,
140
+ * counted; read from the changed paths here, a path never leaves. Instructions (CLAUDE.md, AGENTS.md, GEMINI.md, Cursor
141
+ * rules, Copilot instructions), skills, subagents, commands, the client's settings (permissions, hooks), MCP servers.
142
+ */
143
+ const CONTEXT_FILES = [
144
+ ["instructions", /(^|[\\/])(CLAUDE|AGENTS|GEMINI)\.md$|(^|[\\/])\.cursorrules$|(^|[\\/])\.cursor[\\/]rules[\\/]|copilot-instructions\.md$|\.instructions\.md$|(^|[\\/])\.windsurfrules$/i],
145
+ ["skills", /(^|[\\/])\.claude[\\/]skills[\\/]|(^|[\\/])SKILL\.md$/],
146
+ ["agents", /(^|[\\/])\.claude[\\/]agents[\\/]|(^|[\\/])\.github[\\/]agents[\\/]/],
147
+ ["commands", /(^|[\\/])\.claude[\\/]commands[\\/]|(^|[\\/])\.github[\\/]prompts[\\/]/],
148
+ ["settings", /(^|[\\/])\.claude[\\/]settings(\.local)?\.json$|(^|[\\/])\.codex[\\/]config\.toml$/],
149
+ ["mcp", /(^|[\\/])\.?mcp\.json$|(^|[\\/])\.cursor[\\/]mcp\.json$|(^|[\\/])\.vscode[\\/]mcp\.json$/],
150
+ ];
151
+ const contextFilesOf = (paths) => { const out = {}; for (const path of paths) for (const [kind, pattern] of CONTEXT_FILES) if (pattern.test(path)) { out[kind] = (out[kind] ?? 0) + 1; break; } return Object.keys(out).length ? out : null; };
138
152
  /** An AI named as a commit's co-author by the tools that sign their work that way (read locally; the name never leaves). */
139
153
  const AI_COAUTHOR = /\b(claude|anthropic|codex|openai|chatgpt|copilot|cursor|devin|gemini|jules|aider|windsurf|cline)\b/i;
140
154
  /** A path that holds tests, by the conventions of the common test runners (read here; the path never leaves). */
@@ -608,7 +622,7 @@ function payloadFor(stretch) {
608
622
  const changed = [...new Set(touched.paths)], tests = changed.filter((path) => TEST_PATH.test(path)).length;
609
623
  // 0.8.7: of the stretch's commits, how many name an AI as co-author (the Co-authored-by trailer, read here, never kept).
610
624
  const coauthored = cwd && touched.shas.length > 0 ? git(cwd, "log", "--no-walk", "--format=%(trailers:key=Co-authored-by,valueonly,separator=%x01)%x00", ...touched.shas.slice(0, 50)).split("\0").filter((entry, index) => index < Math.min(50, touched.shas.length) && AI_COAUTHOR.test(entry)).length : 0;
611
- const derived = stretch.derived ? { ...stretch.derived, ...(changed.length > 0 ? { changes: { files: changed.length, test_files: tests } } : {}), ...(touched.shas.length > 0 ? { authorship: { commits: Math.min(50, touched.shas.length), ai_coauthored: coauthored } } : {}), ...(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;
625
+ const derived = stretch.derived ? { ...stretch.derived, ...(changed.length > 0 ? { changes: { files: changed.length, test_files: tests } } : {}), ...(touched.shas.length > 0 ? { authorship: { commits: Math.min(50, touched.shas.length), ai_coauthored: coauthored } } : {}), ...(contextFilesOf(changed) ? { context_files: contextFilesOf(changed) } : {}), ...(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;
612
626
  const payload = {
613
627
  title: layer ? `AI-assisted work · ${layer}` : "AI-assisted work",
614
628
  kind,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worktrust",
3
- "version": "0.8.7",
3
+ "version": "0.8.9",
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": {
@@ -9,7 +9,7 @@ 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");
12
- const CHECK_KINDS = ["test", "typecheck", "lint", "build", "gate", "ci"], DELIVERY_KINDS = ["commit", "pr", "push", "deploy"];
12
+ const CHECK_KINDS = ["test", "typecheck", "lint", "build", "gate", "ci", "security"], DELIVERY_KINDS = ["commit", "pr", "push", "deploy"];
13
13
 
14
14
  /** THE ALLOWLIST: what a line may carry, each value checked for its type. A key not named here never reaches the archive. */
15
15
  export const COUNTS = ["seconds", "tokens_in", "tokens_out", "tokens_cache_read", "tokens_cache_write", "model_seconds", "tool_seconds", "human_seconds", "idle_seconds", "agent_runs", "agent_seconds", "agent_peak", "interrupts", "steers"];
@@ -102,6 +102,24 @@ export function archiveLine(entry, behaviour = null, { ids = {}, collector, salt
102
102
  if (entry.autonomy && ["ask", "edits", "plan", "auto", "full"].includes(entry.autonomy.mode) && whole(entry.autonomy.switches) && whole(entry.autonomy.plan_turns)) line.autonomy = { mode: entry.autonomy.mode, switches: entry.autonomy.switches, plan_turns: entry.autonomy.plan_turns };
103
103
  const authorship = pick(entry.authorship, ["commits", "ai_coauthored"]);
104
104
  if (authorship && authorship.commits > 0 && authorship.ai_coauthored <= authorship.commits) line.authorship = authorship;
105
+ // PRACTICE (0.8.8): framing, verification quality, risk, tool mix, re-reads, context files; whole counts and flags only.
106
+ const bool = (value) => value === true || value === false;
107
+ if (entry.framing && whole(entry.framing.turns_before_action) && bool(entry.framing.planned_first)) line.framing = { turns_before_action: entry.framing.turns_before_action, planned_first: entry.framing.planned_first };
108
+ if (entry.quality && whole(entry.quality.fix_cycles_max) && (bool(entry.quality.checked_after_change) || entry.quality.checked_after_change === null) && (bool(entry.quality.inspected_first) || entry.quality.inspected_first === null)) {
109
+ line.quality = { checked_after_change: entry.quality.checked_after_change, fix_cycles_max: entry.quality.fix_cycles_max, inspected_first: entry.quality.inspected_first };
110
+ // 0.8.9: change blocks, how many a check followed, rework cycles.
111
+ if (whole(entry.quality.change_blocks) && whole(entry.quality.checked_blocks) && whole(entry.quality.rework_cycles) && entry.quality.checked_blocks <= entry.quality.change_blocks) Object.assign(line.quality, { change_blocks: entry.quality.change_blocks, checked_blocks: entry.quality.checked_blocks, rework_cycles: entry.quality.rework_cycles });
112
+ }
113
+ if (STAGES.includes(entry.stage)) line.stage = entry.stage;
114
+ const risk = pick(entry.risk, ["proposed", "refused", "run"]);
115
+ if (risk && risk.proposed > 0 && risk.refused + risk.run <= risk.proposed) line.risk = risk;
116
+ const keyed = (record, keys) => { const kept = Object.entries(record && typeof record === "object" ? record : {}).filter(([key, n]) => keys.includes(key) && whole(n) && n > 0).sort(); return kept.length ? Object.fromEntries(kept) : null; };
117
+ const mix = keyed(entry.tool_mix, ["search", "read", "edit", "shell", "agent", "web", "data", "mcp", "plan", "other"]);
118
+ if (mix) line.tool_mix = mix;
119
+ const reads = pick(entry.reads, ["repeated"]);
120
+ if (reads) line.reads = reads;
121
+ const files = keyed(entry.context_files, ["instructions", "skills", "agents", "commands", "settings", "mcp"]);
122
+ if (files) line.context_files = files;
105
123
  // The behaviour signals of this stretch (0.7.0): keys of the counter's rubric and whole counts, never a word of a turn.
106
124
  if (behaviour && behaviour.signals && typeof behaviour.analyzer_version === "string" && /^counter@\d+\.\d+\.\d+$/.test(behaviour.analyzer_version)) {
107
125
  const kept = Object.entries(behaviour.signals).filter(([key, count]) => SIGNAL.test(key) && Number.isInteger(count) && count > 0).sort();
@@ -149,7 +167,9 @@ export function archiveEntries(dir, ownKey) {
149
167
  * a line of their own that names the stretch it adds to (`supplements`) and carries only what no earlier line of that
150
168
  * stretch carries, in the same chain, under the same day's root. Null when there is nothing new.
151
169
  */
152
- export const DERIVED_KEYS = ["signals", "analyzer_version", "verification", "unconfirmed", "delivery", "recovery", "delegation", "context", "routing", "steering", "tools", "complexity", "oversight", "planning", "changes", "autonomy", "authorship", "interrupts", "steers", "utc_offset"];
170
+ /** The furthest step a stretch reached on the computer (0.8.9). */
171
+ const STAGES = ["attempted", "verified", "committed", "pushed", "pr", "deployed"];
172
+ export const DERIVED_KEYS = ["signals", "analyzer_version", "verification", "unconfirmed", "delivery", "recovery", "delegation", "context", "routing", "steering", "tools", "complexity", "oversight", "planning", "changes", "autonomy", "authorship", "framing", "quality", "risk", "tool_mix", "reads", "context_files", "stage", "interrupts", "steers", "utc_offset"];
153
173
  export function supplementFor(line, earlier) {
154
174
  const missing = DERIVED_KEYS.filter((key) => line[key] !== undefined && !earlier.some((old) => old[key] !== undefined));
155
175
  if (missing.length === 0) return null;
@@ -25,8 +25,12 @@ const CALL_KINDS = [
25
25
  ["pr", /\bgh\s+pr\s+(create|merge)\b/],
26
26
  ["push", /\bgit\s+push\b/],
27
27
  ["deploy", /\b(vercel(\s+deploy)?\s+--prod|fly\s+deploy|netlify\s+deploy|wrangler\s+deploy)\b/],
28
+ // 0.8.8: a security scan is a check of its own; looking at the change before delivering it; and a destructive command.
29
+ ["security", /\b(gitleaks|trufflehog|semgrep|snyk|osv-scanner|trivy|bandit)\b|\b(npm|pnpm|yarn)\s+audit\b/],
30
+ ["inspect", /\bgit\s+(diff|show|status)\b/],
31
+ ["destructive", /\brm\s+-[a-z]*r[a-z]*f|\brm\s+-[a-z]*f[a-z]*r|\bgit\s+(reset\s+--hard|push\s+(-f\b|--force)|clean\s+-[a-z]*f)|\bdrop\s+(table|database|schema)\b|\btruncate\s+table\b|\bkubectl\s+delete\b|\bterraform\s+destroy\b/i],
28
32
  ];
29
- const CHECK_KINDS = ["test", "typecheck", "lint", "build", "gate", "ci"];
33
+ const CHECK_KINDS = ["test", "typecheck", "lint", "build", "gate", "ci", "security"];
30
34
  const DELIVERY_KINDS = ["commit", "pr", "push", "deploy"];
31
35
  export const callKindsOf = (block) => {
32
36
  const command = block && SHELL_TOOLS.has(String(block.name)) ? block.input?.command ?? block.input?.CommandLine ?? block.input?.cmd : null;
@@ -256,9 +260,83 @@ function autonomyOf(messages) {
256
260
  return { mode, switches, plan_turns: tally.plan ?? 0 };
257
261
  }
258
262
 
263
+ /**
264
+ * HOW THE WORK WAS FRAMED, CHECKED AND RISKED (0.8.8; the owner's capability list: problem framing, verification quality,
265
+ * acceptance discipline, risk awareness, tool selection, context efficiency, recovery cycles). All structural, read from
266
+ * the order of turns and calls; nothing of their content.
267
+ * framing: the person's turns before the agent's first action (an edit, a write or a shell command), and whether a plan
268
+ * (plan mode, a plan approved, a to-do list) came before it.
269
+ * quality: whether a check passed AFTER the stretch's last change; the most failures one check family took before it
270
+ * passed; whether the change was looked at (git diff, show, status) before the first delivery step.
271
+ * 0.8.9: change BLOCKS (edits with no check between them) and how many a check followed before the next one;
272
+ * REWORK cycles (a check failed, then a change before that check ran again: change, fail, change).
273
+ * stage: the furthest step the stretch reached on this computer: attempted (it changed something), verified (a check
274
+ * passed), committed, pushed, pr, deployed (0.8.9). Merged and confirmed are the server's, from GitHub.
275
+ * risk: destructive commands proposed, refused (by the person or a guard) and run.
276
+ * tool_mix: calls per kind of tool (search, read, edit, shell, agent, web, data, mcp, plan).
277
+ * reads: files read again unchanged (the same read twice), the cost of context that did not hold.
278
+ */
279
+ const ACTION_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit", "Bash"]);
280
+ const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
281
+ const PLAN_TOOLS = new Set(["TodoWrite", "ExitPlanMode", "AskUserQuestion"]);
282
+ const toolKind = (tool) => EDIT_TOOLS.has(tool) ? "edit" : tool === "Read" ? "read" : ["Grep", "Glob", "WebSearch", "ToolSearch"].includes(tool) ? "search" : tool === "Bash" ? "shell" : ["Agent", "Task"].includes(tool) ? "agent" : /^(WebFetch|browser|mcp__.*(playwright|browser|chrome))/i.test(tool) ? "web" : /sql|postgres|supabase|database|bigquery|snowflake/i.test(tool) ? "data" : /^mcp__/.test(tool) ? "mcp" : PLAN_TOOLS.has(tool) ? "plan" : "other";
283
+ function practiceOf(messages, { isHumanTurn }) {
284
+ const results = new Map();
285
+ for (const message of messages) for (const result of message.results ?? []) results.set(result.id, result);
286
+ let turns = 0, acted = false, planned = false, turnsBefore = 0, plannedFirst = false;
287
+ let lastChange = -1, passedAfter = false, delivered = false, inspectedFirst = false;
288
+ const failuresBefore = new Map(); let cyclesMax = 0;
289
+ let blocks = 0, checkedBlocks = 0, inBlock = false, rework = 0, passedAny = false;
290
+ const pendingFail = new Set(), reached = new Set();
291
+ const risk = { proposed: 0, refused: 0, run: 0 }, mix = {}, reads = new Map();
292
+ let index = 0;
293
+ for (const message of messages) {
294
+ if (message.bridge) continue;
295
+ if (message.type === "user" && !message.meta && message.kind !== "tool_result" && isHumanTurn(message.content)) turns += 1;
296
+ if (message.permissionMode === "plan") planned = true;
297
+ for (const call of message.calls ?? []) {
298
+ index += 1;
299
+ const tool = call.family.split("|")[0], result = results.get(call.id);
300
+ mix[toolKind(tool)] = (mix[toolKind(tool)] ?? 0) + 1;
301
+ if (PLAN_TOOLS.has(tool) && tool !== "AskUserQuestion") planned = true;
302
+ if (!acted && ACTION_TOOLS.has(tool)) { acted = true; turnsBefore = turns; plannedFirst = planned; }
303
+ if (EDIT_TOOLS.has(tool)) {
304
+ lastChange = index; passedAfter = false;
305
+ if (!inBlock) { blocks += 1; inBlock = true; }
306
+ rework += pendingFail.size; pendingFail.clear();
307
+ }
308
+ if (tool === "Read") reads.set(call.digest, (reads.get(call.digest) ?? 0) + 1);
309
+ const checks = call.kinds.filter((kind) => CHECK_KINDS.includes(kind));
310
+ if (checks.length > 0 && inBlock) { checkedBlocks += 1; inBlock = false; }
311
+ for (const kind of checks) {
312
+ if (result?.failed === true) { failuresBefore.set(kind, (failuresBefore.get(kind) ?? 0) + 1); pendingFail.add(kind); }
313
+ if (result?.failed === false) { passedAny = true; pendingFail.delete(kind); }
314
+ if (result?.failed === false) { cyclesMax = Math.max(cyclesMax, failuresBefore.get(kind) ?? 0); failuresBefore.set(kind, 0); if (index > lastChange) passedAfter = true; }
315
+ }
316
+ if (call.kinds.includes("inspect") && !delivered) inspectedFirst = true;
317
+ if (call.kinds.some((kind) => DELIVERY_KINDS.includes(kind)) && result?.failed === false) { delivered = true; for (const kind of call.kinds) if (DELIVERY_KINDS.includes(kind)) reached.add(kind); }
318
+ if (call.kinds.includes("destructive")) {
319
+ risk.proposed += 1;
320
+ if (result?.refusal) risk.refused += 1; else if (result?.failed === false) risk.run += 1;
321
+ }
322
+ }
323
+ }
324
+ if (index === 0) return null;
325
+ const repeated = [...reads.values()].reduce((sum, n) => sum + Math.max(0, n - 1), 0);
326
+ const stage = ["deploy", "pr", "push", "commit"].find((kind) => reached.has(kind)) ?? (lastChange > 0 ? (passedAny ? "verified" : "attempted") : null);
327
+ return {
328
+ framing: { turns_before_action: acted ? turnsBefore : turns, planned_first: acted ? plannedFirst : planned },
329
+ quality: { checked_after_change: lastChange > 0 ? passedAfter : null, fix_cycles_max: cyclesMax, inspected_first: delivered ? inspectedFirst : null, change_blocks: blocks, checked_blocks: checkedBlocks, rework_cycles: rework },
330
+ ...(stage ? { stage: { deploy: "deployed", pr: "pr", push: "pushed", commit: "committed" }[stage] ?? stage } : {}),
331
+ ...(risk.proposed > 0 ? { risk } : {}),
332
+ tool_mix: mix,
333
+ reads: { repeated },
334
+ };
335
+ }
336
+
259
337
  /** The stretch's whole derived record; `helpers` are the hook's own readers of a human turn, so both read it the same way. */
260
338
  export function deriveStretch(messages, helpers) {
261
339
  const context = contextOf(messages, helpers), routing = routingOf(messages), steering = steeringOf(messages, helpers);
262
- const tools = toolsOf(messages), oversight = oversightOf(messages), planning = planningOf(messages), autonomy = autonomyOf(messages);
263
- return { ...(oversight ? { oversight } : {}), ...(planning ? { planning } : {}), ...(autonomy ? { autonomy } : {}), ...verificationOf(messages, helpers), ...(context ? { context } : {}), ...(routing ? { routing } : {}), ...(steering ? { steering } : {}), ...(tools ? { tools } : {}) };
340
+ const tools = toolsOf(messages), oversight = oversightOf(messages), planning = planningOf(messages), autonomy = autonomyOf(messages), practice = practiceOf(messages, helpers);
341
+ return { ...(practice ?? {}), ...(oversight ? { oversight } : {}), ...(planning ? { planning } : {}), ...(autonomy ? { autonomy } : {}), ...verificationOf(messages, helpers), ...(context ? { context } : {}), ...(routing ? { routing } : {}), ...(steering ? { steering } : {}), ...(tools ? { tools } : {}) };
264
342
  }
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.7";
76
+ const CLI_VERSION = "0.8.9";
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");