worktrust 0.7.0 → 0.7.2

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
@@ -159,6 +159,8 @@ nothing it does not:
159
159
  | `stretch_ref` | a hash of the session's id and the day, so a stretch is archived once |
160
160
  | `device_id`, `profile_id` | which computer and which user profile on it, as one-way hashes (the machine's own id never appears) |
161
161
  | `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) |
162
+ | `verification`, `delivery` | per kind of check run in the stretch (test, typecheck, lint, build, the project's gate, CI) how many ran and how many failed; per delivery step (commit, PR, push, deploy) how many succeeded, and whether a check had passed before the first; named from each command on this computer, never the command itself (0.7.1) |
163
+ | `recovery` | of the tool calls that failed in the stretch, how many a later call of the same kind recovered, the middle time that took, how many were retried unchanged and how many with a different approach; inputs compared on this computer by digest, never kept (0.7.2) |
162
164
  | `collector_version` | the CLI version that measured it |
163
165
  | `seq`, `prev`, `hash` | the line's place, the hash of the line before it, and its own hash |
164
166
 
package/log-session.mjs CHANGED
@@ -135,6 +135,33 @@ 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
+ * WHAT A TOOL CALL WAS, AS A CATEGORY (0.7.1, verification per stretch; the capability roadmap's second tranche). A shell
140
+ * command is read HERE to name its kind: a check (test, typecheck, lint, build, CI, the project's own gate) or a step of
141
+ * delivery (commit, PR, push, deploy). The command itself never leaves this function; only the kind does, and only into
142
+ * the local archive. A chained command can be several kinds; its one outcome counts for each.
143
+ */
144
+ const SHELL_TOOLS = new Set(["Bash", "bash", "shell", "run_command", "run_shell_command", "execute_command", "terminal"]);
145
+ const CALL_KINDS = [
146
+ ["test", /(^|[\s/;&|])(vitest|jest|pytest|mocha|rspec|phpunit)\b|\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b|\bgo test\b|\bcargo test\b|\bnode --test\b|\bplaywright test\b|scripts\/test-[\w-]+\.m?[jt]s\b/],
147
+ ["typecheck", /\btsc\b|\b(npm|pnpm|yarn|bun)\s+(run\s+)?typecheck\b|\bmypy\b|\bpyright\b/],
148
+ ["lint", /\beslint\b|\b(npm|pnpm|yarn|bun)\s+(run\s+)?lint\b|\bruff\b|\bflake8\b|\bclippy\b|\bbiome\s+(check|lint)\b/],
149
+ ["build", /\b(npm|pnpm|yarn|bun)\s+(run\s+)?build\b|\bnext build\b|\bvite build\b|\bcargo build\b|\bgo build\b/],
150
+ ["gate", /\b(npm|pnpm|yarn|bun)\s+(run\s+)?(gate|verify|check)\b|scripts\/check-[\w-]+\.m?[jt]s\b/],
151
+ ["ci", /\bgh\s+(run\s+(watch|view)|pr\s+checks)\b/],
152
+ ["commit", /\bgit\s+commit\b/],
153
+ ["pr", /\bgh\s+pr\s+(create|merge)\b/],
154
+ ["push", /\bgit\s+push\b/],
155
+ ["deploy", /\b(vercel(\s+deploy)?\s+--prod|fly\s+deploy|netlify\s+deploy|wrangler\s+deploy)\b/],
156
+ ];
157
+ const CHECK_KINDS = ["test", "typecheck", "lint", "build", "gate", "ci"];
158
+ const DELIVERY_KINDS = ["commit", "pr", "push", "deploy"];
159
+ const callKindsOf = (block) => {
160
+ const command = block && SHELL_TOOLS.has(String(block.name)) ? block.input?.command ?? block.input?.CommandLine ?? block.input?.cmd : null;
161
+ return typeof command === "string" ? CALL_KINDS.filter(([, pattern]) => pattern.test(command)).map(([kind]) => kind) : [];
162
+ };
163
+ /** The derived record of a stretch, kept for the local archive only (see payloadFor). */
164
+ const DERIVED = new WeakMap();
138
165
  const AGENT_RUNS_MAX = 10000, AGENT_SECONDS_MAX = 2592000;
139
166
 
140
167
  /**
@@ -387,6 +414,10 @@ const messageOf = (line, at) => {
387
414
  // A tool that waits for the PERSON (a question, a plan to approve): the gap to its result is the person's, not the tool's. The name is read, never the input.
388
415
  waitsForPerson: line.type === "assistant" && blocks.some((block) => block && block.type === "tool_use" && WAITS_FOR_PERSON.has(String(block.name))),
389
416
  agentic: line.agentic === true, cumulative: line.cumulative === true, // the session's running totals (Hermes, Goose without a ledger)
417
+ // 0.7.1: each tool call's kinds (never its command) and each tool result's outcome, joined on the call's id in `measure`.
418
+ // 0.7.2: and the call's FAMILY (the tool, and its check kinds) and a digest of its input, compared here and never kept.
419
+ calls: line.type === "assistant" ? blocks.filter((block) => block && block.type === "tool_use" && typeof block.id === "string").map((block) => { const kinds = callKindsOf(block); return { id: block.id, kinds, family: `${String(block.name)}|${kinds.join("+")}`, digest: createHash("sha256").update(JSON.stringify(block.input ?? null)).digest("base64url").slice(0, 16) }; }) : [],
420
+ results: blocks.filter((block) => block && block.type === "tool_result" && typeof block.tool_use_id === "string").map((block) => ({ id: block.tool_use_id, failed: block.is_error === true })),
390
421
  };
391
422
  };
392
423
 
@@ -464,6 +495,56 @@ function isHumanTurn(content) {
464
495
  return types.has("text") || types.has("image");
465
496
  }
466
497
 
498
+ /**
499
+ * VERIFICATION PER STRETCH (0.7.1; framework L7, L12): per kind of check, how many ran and how many failed; per step of
500
+ * delivery, how many succeeded; and whether a check had PASSED before the first delivery step. Null where the stretch
501
+ * ran neither, so "no check seen" is never written as "zero checks".
502
+ */
503
+ function verificationOf(messages) {
504
+ const kinds = new Map();
505
+ for (const message of messages) for (const call of message.calls ?? []) kinds.set(call.id, call.kinds);
506
+ const checks = {}, delivered = {};
507
+ let deliveredAt = null, passedFirst = false;
508
+ for (const message of messages) for (const result of message.results ?? []) {
509
+ for (const kind of kinds.get(result.id) ?? []) {
510
+ 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; }
511
+ else if (DELIVERY_KINDS.includes(kind) && !result.failed) { delivered[kind] = (delivered[kind] ?? 0) + 1; if (deliveredAt === null) deliveredAt = message.at; }
512
+ }
513
+ }
514
+ const any = (record) => Object.keys(record).length > 0;
515
+ const recovery = recoveryOf(messages);
516
+ return { ...(any(checks) ? { verification: checks } : {}), ...(any(delivered) ? { delivery: { ...delivered, verified_first: passedFirst } } : {}), ...(recovery ? { recovery } : {}) };
517
+ }
518
+
519
+ /**
520
+ * RECOVERY PER STRETCH (0.7.2; framework L9): of the tool calls that failed, how many were followed by a success of the
521
+ * same family (the same tool, the same kind of check) later in the stretch, the middle time that took, and what came
522
+ * next: the same input again (a blind retry) or a different one (a changed strategy). Inputs are compared by digest on
523
+ * this computer and never kept. Null when nothing failed.
524
+ */
525
+ function recoveryOf(messages) {
526
+ const calls = [];
527
+ const byId = new Map();
528
+ for (const message of messages) {
529
+ for (const call of message.calls ?? []) { const entry = { ...call, at: message.at, failed: null }; calls.push(entry); byId.set(call.id, entry); }
530
+ for (const result of message.results ?? []) { const call = byId.get(result.id); if (call) call.failed = result.failed; }
531
+ }
532
+ const done = calls.filter((call) => call.failed !== null);
533
+ let failures = 0, recovered = 0, blind = 0, changed = 0;
534
+ const latencies = [];
535
+ done.forEach((call, index) => {
536
+ if (!call.failed) return;
537
+ failures += 1;
538
+ const later = done.slice(index + 1).filter((next) => next.family === call.family);
539
+ if (later.length > 0) { if (later[0].digest === call.digest) blind += 1; else changed += 1; }
540
+ const success = later.find((next) => !next.failed);
541
+ if (success) { recovered += 1; latencies.push(Math.max(0, Math.round((success.at - call.at) / 1000))); }
542
+ });
543
+ if (failures === 0) return null;
544
+ latencies.sort((a, b) => a - b);
545
+ return { failures, recovered, blind_retries: blind, strategy_changed: changed, ...(latencies.length ? { median_seconds: latencies[Math.floor((latencies.length - 1) / 2)] } : {}) };
546
+ }
547
+
467
548
  /** One day's messages → the stretch, measured. Null when there is nothing honest to send. */
468
549
  function measure(cwd, messages) {
469
550
  // MEASURED, AND ONLY MEASURED: every second counted lies between two recorded timestamps, and a
@@ -494,7 +575,7 @@ function measure(cwd, messages) {
494
575
  else if (isHumanTurn(message.content) && (prev.kind === "tool_result" || prev.toolUse)) steers += 1;
495
576
  }
496
577
  const from = firstOf(messages);
497
- return { cwd, seconds: measured, layers: measured !== null ? timeLayers(messages) : null, ...tokens, cumulative: messages.some((message) => message.cumulative), exchanges, interrupts, steers, model, from, to: messages[messages.length - 1].at, offset: offsetAt(from) };
578
+ return { cwd, seconds: measured, derived: verificationOf(messages), layers: measured !== null ? timeLayers(messages) : null, ...tokens, cumulative: messages.some((message) => message.cumulative), exchanges, interrupts, steers, model, from, to: messages[messages.length - 1].at, offset: offsetAt(from) };
498
579
  }
499
580
 
500
581
  /**
@@ -579,7 +660,7 @@ function payloadFor(stretch) {
579
660
  // The seventh kind, used as it is defined: a stretch that changed no artefact is knowing
580
661
  // something, not building something, and it earns neither an artefact's nor a validation's credit.
581
662
  const kind = touched.paths.length === 0 ? "researched" : touched.committed ? "changed" : "built";
582
- return {
663
+ const payload = {
583
664
  title: layer ? `AI-assisted work · ${layer}` : "AI-assisted work",
584
665
  kind,
585
666
  ...(layer ? { layer } : {}),
@@ -610,6 +691,9 @@ function payloadFor(stretch) {
610
691
  ...(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) } : {}),
611
692
  at: new Date(stretch.to).toISOString(),
612
693
  };
694
+ // WHAT STAYS ON THIS COMPUTER (0.7.1): the stretch's derived record rides beside the payload for the local archive, never in it.
695
+ if (stretch.derived) DERIVED.set(payload, stretch.derived);
696
+ return payload;
613
697
  }
614
698
 
615
699
  /**
@@ -901,7 +985,7 @@ const doorFor = (entry) => (String(entry?.id ?? "").startsWith("codex:") ? codex
901
985
  const entrySource = new WeakMap(); // an entry → "codex" or "antigravity" by the client that wrote it; the sends route and name by it
902
986
  const entryFile = new WeakMap(); // a history entry → its transcript (`--summary` counts sessions), first moment and folder name (`--archive-lines`)
903
987
  // 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.
904
- if (flag("archive-lines")) { const lines = historyEntries(null, true).map((entry) => JSON.stringify({ ...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
988
+ 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
905
989
  if (!door && !codexDoor && !DRY) { process.exit(0); } // Not coupled on this machine: nothing to do, quietly.
906
990
 
907
991
  if (flag("history")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worktrust",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
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": {
@@ -16,6 +16,7 @@
16
16
  "config-edits.mjs",
17
17
  "preserve.mjs",
18
18
  "preserve-summary.mjs",
19
+ "preserve-lines.mjs",
19
20
  "README.md",
20
21
  "SECURITY.md",
21
22
  "LICENSE"
@@ -0,0 +1,73 @@
1
+ /**
2
+ * WHAT AN ARCHIVE LINE MAY CARRY (split from preserve.mjs, CLI 0.7.1): the allowlist, each value checked for its type,
3
+ * and the behaviour signals joined on `stretch_ref`. A key not named here never reaches the archive. The capability
4
+ * tranches (docs/CAPABILITY-EVIDENCE-ROADMAP.md) add their fields here, each with its own check.
5
+ */
6
+ import { spawnSync } from "node:child_process";
7
+ import { createHash } from "node:crypto";
8
+ import { existsSync } from "node:fs";
9
+ import { join } from "node:path";
10
+
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"];
13
+
14
+ /** THE ALLOWLIST: what a line may carry, each value checked for its type. A key not named here never reaches the archive. */
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"];
16
+ export const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
17
+ /** The hook's own words for a stretch (work area, kind, how it was steered, the measuring bases), kept as the hook sends them. */
18
+ const WORDS = ["layer", "kind", "steered_from", "duration_basis", "token_basis", "turn_basis"];
19
+ const WORD = /^[a-z][A-Za-z]{1,31}$/;
20
+ /**
21
+ * THE STRETCH'S BEHAVIOUR SIGNALS (0.7.0): the local counter's keys and counts per stretch, joined on `stretch_ref`,
22
+ * with the rubric's version. Read on this computer by count-behaviour.mjs --stretch-signals, which sends nothing; a
23
+ * counter that is missing, fails or answers cut leaves the archive without signals and says so, never half of them.
24
+ */
25
+ export function stretchSignals(here, say) {
26
+ const counter = join(here, "count-behaviour.mjs");
27
+ if (!existsSync(counter)) return null;
28
+ const run = spawnSync(process.execPath, [counter, "--stretch-signals", "--months", "120"], { encoding: "utf8", maxBuffer: 1 << 29, env: { ...process.env, WORKTRUST_MCP_URL: "", WORKTRUST_MCP_TOKEN: "" } });
29
+ const rows = (run.stdout ?? "").split("\n").filter((text) => text.startsWith("{")).map((text) => { try { return JSON.parse(text); } catch { return null; } });
30
+ const end = rows.at(-1)?.stretch_signals_end;
31
+ if (run.status !== 0 || !Number.isInteger(end) || end !== rows.length - 1) { say(" The behaviour counter did not answer whole, so these lines carry no signals; the next run adds them to new lines."); return null; }
32
+ return new Map(rows.slice(0, -1).filter(Boolean).map((row) => [row.stretch_ref, row]));
33
+ }
34
+ const SIGNAL = /^[a-zA-Z][A-Za-z0-9._-]{1,63}$/;
35
+ export function archiveLine(entry, behaviour = null, { ids = {}, collector } = {}) {
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
+ const line = { client: entry.client, day: entry.started_at.slice(0, 10), started_at: entry.started_at, ended_at: entry.at };
38
+ for (const key of COUNTS) if (typeof entry[key] === "number" && Number.isFinite(entry[key]) && entry[key] >= 0) line[key] = entry[key];
39
+ if (typeof entry.model === "string" && /^[A-Za-z0-9][A-Za-z0-9._:/ -]{0,63}$/.test(entry.model)) line.model = entry.model;
40
+ for (const key of WORDS) if (typeof entry[key] === "string" && WORD.test(entry[key])) line[key] = entry[key];
41
+ if (Array.isArray(entry.layers) && entry.layers.length > 1 && entry.layers.every((word) => typeof word === "string" && WORD.test(word))) line.layers = entry.layers.slice(0, 8);
42
+ if (typeof entry.exchanges === "number" && Number.isInteger(entry.exchanges) && entry.exchanges > 0) line.exchanges = entry.exchanges;
43
+ // The computer's offset from UTC when the stretch began (0.6.18), so a working day can be read in local time.
44
+ if (typeof entry.utc_offset === "string" && /^[+-]\d{2}:\d{2}$/.test(entry.utc_offset)) line.utc_offset = entry.utc_offset;
45
+ // Commit hashes the stretch made, never a message: what ties AI time to delivered work in the repository.
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
+ // 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
+ const name = entry.repo ?? entry.folder;
49
+ if (typeof name === "string" && name) line.project = sha256(`worktrust-project|${name}`);
50
+ // VERIFICATION AND DELIVERY (0.7.1): per kind of check [ran, failed], per delivery step a count, and whether a check had
51
+ // passed before the first; known kinds and whole numbers only, a failure count never above its runs.
52
+ const whole = (value) => Number.isInteger(value) && value >= 0 && value <= 100000;
53
+ if (entry.verification && typeof entry.verification === "object") {
54
+ 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
+ if (kept.length > 0) line.verification = Object.fromEntries(kept);
56
+ }
57
+ if (entry.delivery && typeof entry.delivery === "object") {
58
+ const kept = Object.entries(entry.delivery).filter(([kind, count]) => DELIVERY_KINDS.includes(kind) && whole(count) && count > 0).sort();
59
+ if (kept.length > 0) line.delivery = { ...Object.fromEntries(kept), verified_first: entry.delivery.verified_first === true };
60
+ }
61
+ // RECOVERY (0.7.2): failures, recoveries, blind retries, changed strategies and the middle time to recover, whole numbers.
62
+ if (entry.recovery && typeof entry.recovery === "object") {
63
+ const { failures, recovered, blind_retries: blind, strategy_changed: changed, median_seconds: median } = entry.recovery;
64
+ if ([failures, recovered, blind, changed].every(whole) && failures > 0 && recovered <= failures && blind + changed <= failures && (median === undefined || whole(median))) line.recovery = { failures, recovered, blind_retries: blind, strategy_changed: changed, ...(median !== undefined ? { median_seconds: median } : {}) };
65
+ }
66
+ // The behaviour signals of this stretch (0.7.0): keys of the counter's rubric and whole counts, never a word of a turn.
67
+ if (behaviour && behaviour.signals && typeof behaviour.analyzer_version === "string" && /^counter@\d+\.\d+\.\d+$/.test(behaviour.analyzer_version)) {
68
+ const kept = Object.entries(behaviour.signals).filter(([key, count]) => SIGNAL.test(key) && Number.isInteger(count) && count > 0).sort();
69
+ if (kept.length > 0) { line.signals = Object.fromEntries(kept); line.analyzer_version = behaviour.analyzer_version; }
70
+ }
71
+ return { ...line, stretch_ref: entry.stretch_ref, ...ids, collector_version: collector };
72
+ }
73
+
@@ -10,10 +10,12 @@
10
10
  export function summaryLines(lines) {
11
11
  const out = [];
12
12
  const months = new Map();
13
- const month = (key) => months.get(key) ?? months.set(key, { seconds: 0, models: new Map(), input: 0, cached: 0, days: new Map() }).get(key);
13
+ const month = (key) => months.get(key) ?? months.set(key, { seconds: 0, models: new Map(), input: 0, cached: 0, days: new Map(), delivered: 0, verifiedFirst: 0 }).get(key);
14
14
  for (const line of lines) {
15
15
  const m = month(line.day.slice(0, 7));
16
16
  m.seconds += line.seconds ?? 0;
17
+ // 0.7.1: stretches that delivered (a commit, a PR, a push, a deploy), and of them, those where a check passed first.
18
+ if (line.delivery) { m.delivered += 1; if (line.delivery.verified_first === true) m.verifiedFirst += 1; }
17
19
  if (typeof line.tokens_in === "number" || typeof line.tokens_out === "number") {
18
20
  const name = line.model ?? "model not recorded";
19
21
  m.models.set(name, (m.models.get(name) ?? 0) + (line.tokens_in ?? 0) + (line.tokens_out ?? 0));
@@ -26,6 +28,7 @@ export function summaryLines(lines) {
26
28
  out.push(` ${key} ${hours(m.seconds)} h measured`);
27
29
  for (const [model, tokens] of [...m.models].sort((a, b) => b[1] - a[1])) out.push(` ${model.padEnd(28)} ${tokens.toLocaleString("en")} tokens`);
28
30
  if (m.input > 0) out.push(` cache share of input ${Math.round((m.cached / m.input) * 100)}%`);
31
+ if (m.delivered > 0) out.push(` verified before delivery ${m.verifiedFirst} of ${m.delivered} stretch${m.delivered === 1 ? "" : "es"} that delivered`);
29
32
  let parallelDays = 0, peak = 1, summed = 0, wall = 0;
30
33
  for (const spans of m.days.values()) {
31
34
  const edges = spans.flatMap(([from, to]) => [[from, 1], [to, -1]]).sort((a, b) => a[0] - b[0] || a[1] - b[1]);
package/preserve.mjs CHANGED
@@ -31,6 +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
35
 
35
36
  const args = process.argv.slice(2).filter((arg) => arg !== "preserve");
36
37
  const has = (name) => args.includes(`--${name}`);
@@ -167,12 +168,6 @@ async function apply(list, agreed = false) {
167
168
  say(" Restart the apps whose settings changed. Nothing was sent anywhere.");
168
169
  }
169
170
 
170
- /** THE ALLOWLIST: what a line may carry, each value checked for its type. A key not named here never reaches the archive. */
171
- 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"];
172
- const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
173
- /** The hook's own words for a stretch (work area, kind, how it was steered, the measuring bases), kept as the hook sends them. */
174
- const WORDS = ["layer", "kind", "steered_from", "duration_basis", "token_basis", "turn_basis"];
175
- const WORD = /^[a-z][A-Za-z]{1,31}$/;
176
171
  /** WHICH COMPUTER, WHICH PROFILE: the one-way hashes `worktrust connect` derives (worktrust.mjs `computerIds`), the same text, so they match. */
177
172
  function computerIds() {
178
173
  let machine = null;
@@ -187,44 +182,6 @@ function computerIds() {
187
182
  const hash = (text) => createHash("sha256").update(text).digest("base64url");
188
183
  return { device_id: hash(`worktrust-machine|${machine.toLowerCase()}`), profile_id: hash(`worktrust-profile|${machine.toLowerCase()}|${homedir()}`) };
189
184
  }
190
- /**
191
- * THE STRETCH'S BEHAVIOUR SIGNALS (0.7.0): the local counter's keys and counts per stretch, joined on `stretch_ref`,
192
- * with the rubric's version. Read on this computer by count-behaviour.mjs --stretch-signals, which sends nothing; a
193
- * counter that is missing, fails or answers cut leaves the archive without signals and says so, never half of them.
194
- */
195
- function stretchSignals() {
196
- const counter = join(HERE, "count-behaviour.mjs");
197
- if (!existsSync(counter)) return null;
198
- const run = spawnSync(process.execPath, [counter, "--stretch-signals", "--months", "120"], { encoding: "utf8", maxBuffer: 1 << 29, env: { ...process.env, WORKTRUST_MCP_URL: "", WORKTRUST_MCP_TOKEN: "" } });
199
- const rows = (run.stdout ?? "").split("\n").filter((text) => text.startsWith("{")).map((text) => { try { return JSON.parse(text); } catch { return null; } });
200
- const end = rows.at(-1)?.stretch_signals_end;
201
- if (run.status !== 0 || !Number.isInteger(end) || end !== rows.length - 1) { say(" The behaviour counter did not answer whole, so these lines carry no signals; the next run adds them to new lines."); return null; }
202
- return new Map(rows.slice(0, -1).filter(Boolean).map((row) => [row.stretch_ref, row]));
203
- }
204
- const SIGNAL = /^[a-zA-Z][A-Za-z0-9._-]{1,63}$/;
205
- function archiveLine(entry, behaviour = null) {
206
- 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;
207
- const line = { client: entry.client, day: entry.started_at.slice(0, 10), started_at: entry.started_at, ended_at: entry.at };
208
- for (const key of COUNTS) if (typeof entry[key] === "number" && Number.isFinite(entry[key]) && entry[key] >= 0) line[key] = entry[key];
209
- if (typeof entry.model === "string" && /^[A-Za-z0-9][A-Za-z0-9._:/ -]{0,63}$/.test(entry.model)) line.model = entry.model;
210
- for (const key of WORDS) if (typeof entry[key] === "string" && WORD.test(entry[key])) line[key] = entry[key];
211
- if (Array.isArray(entry.layers) && entry.layers.length > 1 && entry.layers.every((word) => typeof word === "string" && WORD.test(word))) line.layers = entry.layers.slice(0, 8);
212
- if (typeof entry.exchanges === "number" && Number.isInteger(entry.exchanges) && entry.exchanges > 0) line.exchanges = entry.exchanges;
213
- // The computer's offset from UTC when the stretch began (0.6.18), so a working day can be read in local time.
214
- if (typeof entry.utc_offset === "string" && /^[+-]\d{2}:\d{2}$/.test(entry.utc_offset)) line.utc_offset = entry.utc_offset;
215
- // Commit hashes the stretch made, never a message: what ties AI time to delivered work in the repository.
216
- 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);
217
- // 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.
218
- const name = entry.repo ?? entry.folder;
219
- if (typeof name === "string" && name) line.project = sha256(`worktrust-project|${name}`);
220
- // The behaviour signals of this stretch (0.7.0): keys of the counter's rubric and whole counts, never a word of a turn.
221
- if (behaviour && behaviour.signals && typeof behaviour.analyzer_version === "string" && /^counter@\d+\.\d+\.\d+$/.test(behaviour.analyzer_version)) {
222
- const kept = Object.entries(behaviour.signals).filter(([key, count]) => SIGNAL.test(key) && Number.isInteger(count) && count > 0).sort();
223
- if (kept.length > 0) { line.signals = Object.fromEntries(kept); line.analyzer_version = behaviour.analyzer_version; }
224
- }
225
- return { ...line, stretch_ref: entry.stretch_ref, ...IDS, collector_version: COLLECTOR };
226
- }
227
-
228
185
  /** The device key, when this computer is coupled: its private half signs, its public half goes into the proof. */
229
186
  function deviceKey() {
230
187
  try {
@@ -302,12 +259,12 @@ function archive(dir) {
302
259
  // 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.
303
260
  const rows = hook.stdout.split("\n").filter(Boolean), end = (() => { try { return JSON.parse(rows.at(-1) ?? "").archive_end; } catch { return undefined; } })();
304
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); }
305
- const signals = stretchSignals();
262
+ const signals = stretchSignals(HERE, say);
306
263
  const known = new Set(existing.lines.map((line) => line.stretch_ref)), fresh = [], measured = new Set();
307
264
  let waiting = 0;
308
265
  for (const text of rows.slice(0, -1)) {
309
266
  let entry; try { entry = JSON.parse(text); } catch { continue; }
310
- const line = archiveLine(entry, signals?.get(entry.stretch_ref) ?? null);
267
+ const line = archiveLine(entry, signals?.get(entry.stretch_ref) ?? null, { ids: IDS, collector: COLLECTOR });
311
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
312
269
  if (line.day >= today()) { waiting += 1; continue; }
313
270
  known.add(line.stretch_ref);
package/worktrust.mjs CHANGED
@@ -71,7 +71,7 @@ const command = args.find((arg, at) => !arg.startsWith("--") && !(at > 0 && VALU
71
71
  const flag = (name) => { const at = args.indexOf(`--${name}`); return at >= 0 ? args[at + 1] : undefined; };
72
72
  const has = (name) => args.includes(`--${name}`);
73
73
  /** This CLI's version, said to the door so the app can tell which computer runs an old one (check-cli-package holds it equal to package.json). */
74
- const CLI_VERSION = "0.7.0";
74
+ const CLI_VERSION = "0.7.2";
75
75
  const ORIGIN = (flag("origin") ?? process.env.WORKTRUST_ORIGIN ?? "https://app.worktrust.io").replace(/\/$/, "");
76
76
  const MCP = flag("url") ?? process.env.WORKTRUST_MCP_URL ?? `${ORIGIN}/api/mcp`;
77
77
  const HOME_DIR = join(homedir(), ".worktrust");