worktrust 0.9.2 → 0.9.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
@@ -177,6 +177,8 @@ nothing it does not:
177
177
  | `timing` | how fast the stretch moved: seconds from your first turn to the first action and from the first change to the first check, the actions and seconds from a failure to the change that answered it, the calls from a failed check to it passing, and the calls back to work after a compaction; medians from the order and the clocks, never what was said (0.9.0) |
178
178
  | `hygiene` | risk and hygiene: rollbacks, privileged commands (sudo, a forced push), hooks skipped (--no-verify), secrets touched (a .env printed, a private key read), destructive commands and how many a look or a check came before, the answer to a guard (retried, changed, stopped), and whether a check followed delivery; counts only, never the command or the path (0.9.1) |
179
179
  | `route` | the route the work took: branches made, pushes straight to the default branch, and of the stretch's own commits those over twenty files, a fix that followed another, those naming an issue, and the changed CI, script and docs files; counts only, never a subject, a branch name or a path (0.9.2) |
180
+ | `adaptation` | evidence and adaptation: what the stretch read by kind (docs, tests, logs, config, source, the web), a plan written again after a failure, a model changed right after a failure, two or more agents whose work a check met, and outages (overloaded, rate-limited, timed out) and how many the work went on past; counts only, never a path or a message (0.9.3) |
181
+ | `reach` | the work's reach and its plan: commands on infrastructure (terraform, kubectl, helm, docker), data (psql, sqlite, bq, dbt) and cloud (aws, gcloud, az, supabase, vercel); plan or roadmap documents written and whether one came before the first code change; reviews approved, sent back or commented and issues opened or closed from the terminal; counts and one flag, never a command, a file name or a path (0.9.4) |
180
182
  | `collector_version` | the CLI version that measured it |
181
183
  | `seq`, `prev`, `hash` | the line's place, the hash of the line before it, and its own hash |
182
184
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worktrust",
3
- "version": "0.9.2",
3
+ "version": "0.9.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": {
@@ -127,6 +127,19 @@ export function archiveLine(entry, behaviour = null, { ids = {}, collector, salt
127
127
  const route = Object.fromEntries(ROUTE_KEYS.filter((key) => whole(entry.route[key])).map((key) => [key, entry.route[key]]));
128
128
  if (Object.keys(route).length > 0) line.route = route;
129
129
  }
130
+ // 0.9.3: evidence read and adaptation; whole counts, and reads by kind.
131
+ if (entry.adaptation && typeof entry.adaptation === "object") {
132
+ const adaptation = Object.fromEntries(ADAPTATION_KEYS.filter((key) => whole(entry.adaptation[key])).map((key) => [key, entry.adaptation[key]]));
133
+ const sources = entry.adaptation.sources && typeof entry.adaptation.sources === "object" ? Object.fromEntries(READ_KINDS.filter((kind) => whole(entry.adaptation.sources[kind]) && entry.adaptation.sources[kind] > 0).map((kind) => [kind, entry.adaptation.sources[kind]])) : {};
134
+ if (Object.keys(sources).length > 0) adaptation.sources = sources;
135
+ if (Object.keys(adaptation).length > 0) line.adaptation = adaptation;
136
+ }
137
+ // 0.9.4: the work's reach and its plan; whole counts, and whether a plan came first.
138
+ if (entry.reach && typeof entry.reach === "object") {
139
+ const reach = Object.fromEntries(REACH_KEYS.filter((key) => whole(entry.reach[key])).map((key) => [key, entry.reach[key]]));
140
+ if (typeof entry.reach.plan_first === "boolean" && reach.plan_docs) reach.plan_first = entry.reach.plan_first;
141
+ if (Object.keys(reach).length > 0) line.reach = reach;
142
+ }
130
143
  const risk = pick(entry.risk, ["proposed", "refused", "run"]);
131
144
  if (risk && risk.proposed > 0 && risk.refused + risk.run <= risk.proposed) line.risk = risk;
132
145
  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; };
@@ -186,12 +199,17 @@ export function archiveEntries(dir, ownKey) {
186
199
  /** The furthest step a stretch reached on the computer (0.8.9). */
187
200
  const STAGES = ["attempted", "verified", "committed", "pushed", "pr", "deployed"];
188
201
  /** How fast a stretch moved (0.9.0). */
202
+ /** The work's reach and its plan (0.9.4). */
203
+ const REACH_KEYS = ["infra", "data", "cloud", "plan_docs", "reviews_approved", "reviews_changes", "reviews_commented", "issues_opened", "issues_closed"];
204
+ /** Evidence read and adaptation (0.9.3). */
205
+ const ADAPTATION_KEYS = ["replans", "escalations", "reconciled", "outages", "outages_continued"];
206
+ const READ_KINDS = ["docs", "tests", "logs", "config", "source", "web"];
189
207
  /** The route the work took (0.9.2). */
190
208
  const ROUTE_KEYS = ["branches", "pushes_to_main", "commits_large", "fix_followups", "issue_refs", "ci_files", "script_files", "doc_files"];
191
209
  /** Risk and hygiene (0.9.1). */
192
210
  const HYGIENE_KEYS = ["rollbacks", "privileged", "bypasses", "secrets", "destructive", "destructive_checked", "guard_retried", "guard_changed", "guard_stopped"];
193
211
  const TIMING_KEYS = ["first_action_s", "first_check_s", "detect_actions", "detect_s", "recovery_calls", "resume_actions"];
194
- 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", "timing", "hygiene", "route", "interrupts", "steers", "utc_offset"];
212
+ 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", "timing", "hygiene", "route", "adaptation", "reach", "interrupts", "steers", "utc_offset"];
195
213
  export function supplementFor(line, earlier) {
196
214
  const missing = DERIVED_KEYS.filter((key) => line[key] !== undefined && !earlier.some((old) => old[key] !== undefined));
197
215
  if (missing.length === 0) return null;
@@ -49,7 +49,19 @@ const MARKS = [
49
49
  // 0.9.2: the route the work took: a branch of its own, or a push straight to the default branch.
50
50
  ["branch", /\bgit\s+(checkout\s+-b|switch\s+-c)\s|\bgit\s+branch\s+(?!-)[\w./-]+/],
51
51
  ["push_main", /\bgit\s+push\b[^\n;&|]*\s(main|master)\b/],
52
+ // 0.9.4: the kind of system a command worked on, and reviews and issues handled from the terminal.
53
+ ["infra", /(^|[\s;&|(])(terraform|tofu|pulumi|kubectl|helm|kustomize|ansible(-playbook)?|docker(\s+compose)?|docker-compose|podman)\s/],
54
+ ["data", /(^|[\s;&|(])(psql|pg_dump|pg_restore|mysql|sqlite3|duckdb|mongosh|redis-cli|bq|snowsql|dbt|clickhouse(-client)?)\b/],
55
+ ["cloud", /(^|[\s;&|(])(aws|gcloud|gsutil|az|vercel|netlify|fly|flyctl|wrangler|supabase|firebase|heroku|doctl)\s/],
56
+ ["review_approve", /\bgh\s+pr\s+review\b[^\n;&|]*\s(-a|--approve)\b/],
57
+ ["review_changes", /\bgh\s+pr\s+review\b[^\n;&|]*\s(-r|--request-changes)\b/],
58
+ ["review_comment", /\bgh\s+pr\s+review\b[^\n;&|]*\s(-c|--comment)\b/],
59
+ ["issue_open", /\bgh\s+issue\s+create\b/],
60
+ ["issue_close", /\bgh\s+issue\s+close\b/],
52
61
  ];
62
+ /** A plan or roadmap the work wrote down (0.9.4), by the file's name; the name never leaves. */
63
+ const PLAN_DOC = /(^|[\\/])(PLAN|ROADMAP|DESIGN|SPEC|RFC|ADR)[\w.-]*\.(md|mdx|txt)$|(^|[\\/])(plans?|roadmaps?|rfcs?|adrs?|specs?)[\\/][^\\/]+\.(md|mdx)$|\.plan\.md$/i;
64
+ const WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "write_file", "edit_file", "create_file"]);
53
65
  const SECRET_FILE = /(^|[\/\s'"])(\.env(\.[\w-]+)?|id_(rsa|ed25519|ecdsa)|[\w.-]+\.(pem|p12|pfx|key)|credentials(\.json)?|\.npmrc|\.netrc|\.pgpass)(['"\s]|$)/;
54
66
  const PRINTS = /\b(cat|less|more|head|tail|bat|type|Get-Content|grep|sed|awk|cp|scp|base64)\b/;
55
67
  const READ_TOOLS = new Set(["Read", "read_file", "view", "open_file"]);
@@ -58,10 +70,29 @@ export const marksOf = (block) => {
58
70
  const command = SHELL_TOOLS.has(name) ? block.input?.command ?? block.input?.CommandLine ?? block.input?.cmd : null;
59
71
  const path = READ_TOOLS.has(name) ? block.input?.file_path ?? block.input?.path ?? null : null;
60
72
  const marks = typeof command === "string" ? MARKS.filter(([, pattern]) => pattern.test(command)).map(([mark]) => mark) : [];
73
+ const written = WRITE_TOOLS.has(name) ? block.input?.file_path ?? block.input?.path ?? null : null;
74
+ if (typeof written === "string" && PLAN_DOC.test(written)) marks.push("plan_doc");
61
75
  if ((typeof path === "string" && SECRET_FILE.test(path)) || (typeof command === "string" && PRINTS.test(command) && SECRET_FILE.test(command))) marks.push("secret");
62
76
  return marks;
63
77
  };
64
78
 
79
+ /**
80
+ * WHAT A READ WAS (0.9.3), by kind: docs, tests, logs, config, source, or the web. The path is read here and never kept.
81
+ */
82
+ const READ_KINDS = [
83
+ ["docs", /(^|[\\/])docs?[\\/]|(^|[\\/])README|\.(md|mdx|rst|txt)$/i],
84
+ ["tests", /(^|[\\/])(__tests__|tests?|spec|e2e)[\\/]|\.(test|spec)\.[cm]?[jt]sx?$|_test\.(go|py|rb)$/],
85
+ ["logs", /\.log$|(^|[\\/])logs?[\\/]/i],
86
+ ["config", /\.(json|ya?ml|toml|ini|env\.example)$|(^|[\\/])\.[\w-]+rc$|config/i],
87
+ ];
88
+ export const readKindOf = (block) => {
89
+ const name = String(block?.name ?? "");
90
+ if (/^(WebFetch|WebSearch|web_fetch|web_search)$/.test(name)) return "web";
91
+ const path = READ_TOOLS.has(name) ? block.input?.file_path ?? block.input?.path ?? null : null;
92
+ if (typeof path !== "string") return null;
93
+ return READ_KINDS.find(([, pattern]) => pattern.test(path))?.[0] ?? "source";
94
+ };
95
+
65
96
  /**
66
97
  * 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
67
98
  * refused it when it was refused: the PERSON (Claude Code's fixed sentence when they decline a tool use or a plan) or a
@@ -69,18 +100,21 @@ export const marksOf = (block) => {
69
100
  */
70
101
  const PERSON_REFUSED = /^\s*(The user doesn't want to (proceed|take this action)|User rejected|The user rejected|The user declined)/i;
71
102
  const GUARD_REFUSED = /^\s*Permission for this (command|action) was denied by/i;
103
+ const OUTAGE = /\b(overloaded|rate[ -]?limit(ed)?|too many requests|429|503|service unavailable|timed? ?out|ETIMEDOUT|ECONNRESET|ECONNREFUSED|ENOTFOUND)\b/i;
72
104
  export const resultOf = (block) => {
73
105
  const text = typeof block.content === "string" ? block.content : Array.isArray(block.content) ? block.content.map((part) => (part && typeof part.text === "string" ? part.text : "")).join("") : "";
74
106
  const failed = block.is_error === true;
75
107
  // 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.
76
108
  if (!failed && block.outcome === "unknown") return { id: block.tool_use_id, failed: null, refusal: null };
77
- return { id: block.tool_use_id, failed, refusal: failed && PERSON_REFUSED.test(text) ? "person" : failed && GUARD_REFUSED.test(text) ? "guard" : null };
109
+ // 0.9.3: an OUTAGE (the model or a tool overloaded, rate-limited, timed out, unreachable), read here and never kept.
110
+ const outage = failed && OUTAGE.test(text);
111
+ return { id: block.tool_use_id, failed, refusal: failed && PERSON_REFUSED.test(text) ? "person" : failed && GUARD_REFUSED.test(text) ? "guard" : null, ...(outage ? { outage: true } : {}) };
78
112
  };
79
113
  /** A to-do list the agent wrote (TodoWrite): how many items and how many done, never what they say. */
80
114
  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);
81
115
 
82
116
  /** 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). */
83
- export const callOf = (block) => { const kinds = callKindsOf(block), plan = planOf(block), marks = marksOf(block); return { id: block.id, kinds, ...(marks.length ? { marks } : {}), family: `${String(block.name)}|${kinds.join("+")}`, digest: createHash("sha256").update(JSON.stringify(block.input ?? null)).digest("base64url").slice(0, 16), ...(plan ? { plan } : {}) }; };
117
+ export const callOf = (block) => { const kinds = callKindsOf(block), plan = planOf(block), marks = marksOf(block), read = readKindOf(block); return { id: block.id, kinds, ...(marks.length ? { marks } : {}), ...(read ? { read } : {}), family: `${String(block.name)}|${kinds.join("+")}`, digest: createHash("sha256").update(JSON.stringify(block.input ?? null)).digest("base64url").slice(0, 16), ...(plan ? { plan } : {}) }; };
84
118
 
85
119
  /**
86
120
  * VERIFICATION PER STRETCH (0.7.1; framework L7, L12): per kind of check, how many ran and how many failed; per step of
@@ -453,9 +487,70 @@ function routeOf(messages) {
453
487
  return branches + pushesToMain > 0 ? { ...(branches ? { branches } : {}), ...(pushesToMain ? { pushes_to_main: pushesToMain } : {}) } : null;
454
488
  }
455
489
 
490
+ /**
491
+ * EVIDENCE AND ADAPTATION (0.9.3): what the stretch read, by kind; a plan written again after a failure; a model change
492
+ * within three calls after a failure (escalation); two or more agents whose work a check met before the next delivery
493
+ * (reconciliation, a proxy); and outages (the model or a tool overloaded, rate-limited, timed out) and how many the work
494
+ * went on past (a later call succeeded). Counts only.
495
+ */
496
+ function adaptationOf(messages) {
497
+ const results = new Map();
498
+ for (const message of messages) for (const result of message.results ?? []) results.set(result.id, result);
499
+ const sources = {}, out = { replans: 0, escalations: 0, reconciled: 0, outages: 0, outages_continued: 0 };
500
+ let failedSince = false, lastFailureCall = null, index = 0, model = null, agentsOpen = 0, outageOpen = false;
501
+ for (const message of messages) {
502
+ if (message.bridge) continue;
503
+ if (message.type === "assistant" && message.model) {
504
+ // Once per failure: the change that answered it, not every switch after it.
505
+ if (model && message.model !== model && lastFailureCall !== null && index - lastFailureCall <= 3) { out.escalations += 1; lastFailureCall = null; }
506
+ model = message.model;
507
+ }
508
+ for (const call of message.calls ?? []) {
509
+ index += 1;
510
+ const tool = call.family.split("|")[0], result = results.get(call.id);
511
+ if (call.read) sources[call.read] = (sources[call.read] ?? 0) + 1;
512
+ if (call.plan && failedSince) { out.replans += 1; failedSince = false; }
513
+ if (tool === "Agent" || tool === "Task") agentsOpen += 1;
514
+ if (call.kinds.some((kind) => CHECK_KINDS.includes(kind)) && agentsOpen >= 2) { out.reconciled += 1; agentsOpen = 0; }
515
+ if (call.kinds.some((kind) => DELIVERY_KINDS.includes(kind))) agentsOpen = 0;
516
+ if (result?.outage) { out.outages += 1; outageOpen = true; }
517
+ else if (outageOpen && result?.failed === false) { out.outages_continued += 1; outageOpen = false; }
518
+ if (result?.failed === true) { failedSince = true; lastFailureCall = index; }
519
+ }
520
+ }
521
+ const kept = Object.fromEntries(Object.entries(out).filter(([, value]) => value > 0));
522
+ if (Object.keys(sources).length > 0) kept.sources = sources;
523
+ return Object.keys(kept).length > 0 ? kept : null;
524
+ }
525
+
526
+ /**
527
+ * THE WORK'S REACH AND ITS PLAN (0.9.4): commands on infrastructure, data and cloud; plan or roadmap documents written,
528
+ * and whether one came before the first code change; reviews approved, sent back or commented and issues opened or closed
529
+ * from the terminal. Counts, and one flag.
530
+ */
531
+ function reachOf(messages) {
532
+ const results = new Map();
533
+ for (const message of messages) for (const result of message.results ?? []) results.set(result.id, result);
534
+ const out = { infra: 0, data: 0, cloud: 0, plan_docs: 0, reviews_approved: 0, reviews_changes: 0, reviews_commented: 0, issues_opened: 0, issues_closed: 0 };
535
+ const KEY = { infra: "infra", data: "data", cloud: "cloud", plan_doc: "plan_docs", review_approve: "reviews_approved", review_changes: "reviews_changes", review_comment: "reviews_commented", issue_open: "issues_opened", issue_close: "issues_closed" };
536
+ let changed = false, planFirst = null;
537
+ for (const message of messages) {
538
+ if (message.bridge) continue;
539
+ for (const call of message.calls ?? []) {
540
+ const tool = call.family.split("|")[0], marks = call.marks ?? [], result = results.get(call.id);
541
+ if (marks.includes("plan_doc") && !changed && planFirst === null) planFirst = true;
542
+ if (EDIT_TOOLS.has(tool) && !marks.includes("plan_doc")) { if (!changed && planFirst === null) planFirst = false; changed = true; }
543
+ for (const mark of marks) if (KEY[mark] && result?.failed !== true) out[KEY[mark]] += 1;
544
+ }
545
+ }
546
+ const kept = Object.fromEntries(Object.entries(out).filter(([, value]) => value > 0));
547
+ if (out.plan_docs > 0) kept.plan_first = planFirst === true;
548
+ return Object.keys(kept).length > 0 ? kept : null;
549
+ }
550
+
456
551
  /** The stretch's whole derived record; `helpers` are the hook's own readers of a human turn, so both read it the same way. */
457
552
  export function deriveStretch(messages, helpers) {
458
553
  const context = contextOf(messages, helpers), routing = routingOf(messages), steering = steeringOf(messages, helpers);
459
- const timing = timingOf(messages, helpers), hygiene = hygieneOf(messages), route = routeOf(messages), tools = toolsOf(messages), oversight = oversightOf(messages), planning = planningOf(messages), autonomy = autonomyOf(messages), practice = practiceOf(messages, helpers);
460
- return { ...(practice ?? {}), ...(oversight ? { oversight } : {}), ...(planning ? { planning } : {}), ...(autonomy ? { autonomy } : {}), ...verificationOf(messages, helpers), ...(context ? { context } : {}), ...(routing ? { routing } : {}), ...(steering ? { steering } : {}), ...(tools ? { tools } : {}), ...(timing ? { timing } : {}), ...(hygiene ? { hygiene } : {}), ...(route ? { route } : {}) };
554
+ const timing = timingOf(messages, helpers), hygiene = hygieneOf(messages), route = routeOf(messages), adaptation = adaptationOf(messages), reach = reachOf(messages), tools = toolsOf(messages), oversight = oversightOf(messages), planning = planningOf(messages), autonomy = autonomyOf(messages), practice = practiceOf(messages, helpers);
555
+ return { ...(practice ?? {}), ...(oversight ? { oversight } : {}), ...(planning ? { planning } : {}), ...(autonomy ? { autonomy } : {}), ...verificationOf(messages, helpers), ...(context ? { context } : {}), ...(routing ? { routing } : {}), ...(steering ? { steering } : {}), ...(tools ? { tools } : {}), ...(timing ? { timing } : {}), ...(hygiene ? { hygiene } : {}), ...(route ? { route } : {}), ...(adaptation ? { adaptation } : {}), ...(reach ? { reach } : {}) };
461
556
  }
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.9.2";
76
+ const CLI_VERSION = "0.9.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");