worktrust 0.7.0 → 0.7.1

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,7 @@ 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) |
162
163
  | `collector_version` | the CLI version that measured it |
163
164
  | `seq`, `prev`, `hash` | the line's place, the hash of the line before it, and its own hash |
164
165
 
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,9 @@ 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
+ calls: line.type === "assistant" ? blocks.filter((block) => block && block.type === "tool_use" && typeof block.id === "string").map((block) => ({ id: block.id, kinds: callKindsOf(block) })).filter((call) => call.kinds.length > 0) : [],
419
+ 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
420
  };
391
421
  };
392
422
 
@@ -464,6 +494,26 @@ function isHumanTurn(content) {
464
494
  return types.has("text") || types.has("image");
465
495
  }
466
496
 
497
+ /**
498
+ * VERIFICATION PER STRETCH (0.7.1; framework L7, L12): per kind of check, how many ran and how many failed; per step of
499
+ * delivery, how many succeeded; and whether a check had PASSED before the first delivery step. Null where the stretch
500
+ * ran neither, so "no check seen" is never written as "zero checks".
501
+ */
502
+ function verificationOf(messages) {
503
+ const kinds = new Map();
504
+ for (const message of messages) for (const call of message.calls ?? []) kinds.set(call.id, call.kinds);
505
+ const checks = {}, delivered = {};
506
+ let deliveredAt = null, passedFirst = false;
507
+ for (const message of messages) for (const result of message.results ?? []) {
508
+ for (const kind of kinds.get(result.id) ?? []) {
509
+ 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; }
510
+ else if (DELIVERY_KINDS.includes(kind) && !result.failed) { delivered[kind] = (delivered[kind] ?? 0) + 1; if (deliveredAt === null) deliveredAt = message.at; }
511
+ }
512
+ }
513
+ const any = (record) => Object.keys(record).length > 0;
514
+ return { ...(any(checks) ? { verification: checks } : {}), ...(any(delivered) ? { delivery: { ...delivered, verified_first: passedFirst } } : {}) };
515
+ }
516
+
467
517
  /** One day's messages → the stretch, measured. Null when there is nothing honest to send. */
468
518
  function measure(cwd, messages) {
469
519
  // MEASURED, AND ONLY MEASURED: every second counted lies between two recorded timestamps, and a
@@ -494,7 +544,7 @@ function measure(cwd, messages) {
494
544
  else if (isHumanTurn(message.content) && (prev.kind === "tool_result" || prev.toolUse)) steers += 1;
495
545
  }
496
546
  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) };
547
+ 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
548
  }
499
549
 
500
550
  /**
@@ -579,7 +629,7 @@ function payloadFor(stretch) {
579
629
  // The seventh kind, used as it is defined: a stretch that changed no artefact is knowing
580
630
  // something, not building something, and it earns neither an artefact's nor a validation's credit.
581
631
  const kind = touched.paths.length === 0 ? "researched" : touched.committed ? "changed" : "built";
582
- return {
632
+ const payload = {
583
633
  title: layer ? `AI-assisted work · ${layer}` : "AI-assisted work",
584
634
  kind,
585
635
  ...(layer ? { layer } : {}),
@@ -610,6 +660,9 @@ function payloadFor(stretch) {
610
660
  ...(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
661
  at: new Date(stretch.to).toISOString(),
612
662
  };
663
+ // WHAT STAYS ON THIS COMPUTER (0.7.1): the stretch's derived record rides beside the payload for the local archive, never in it.
664
+ if (stretch.derived) DERIVED.set(payload, stretch.derived);
665
+ return payload;
613
666
  }
614
667
 
615
668
  /**
@@ -901,7 +954,7 @@ const doorFor = (entry) => (String(entry?.id ?? "").startsWith("codex:") ? codex
901
954
  const entrySource = new WeakMap(); // an entry → "codex" or "antigravity" by the client that wrote it; the sends route and name by it
902
955
  const entryFile = new WeakMap(); // a history entry → its transcript (`--summary` counts sessions), first moment and folder name (`--archive-lines`)
903
956
  // 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
957
+ 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
958
  if (!door && !codexDoor && !DRY) { process.exit(0); } // Not coupled on this machine: nothing to do, quietly.
906
959
 
907
960
  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.1",
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,68 @@
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
+ // The behaviour signals of this stretch (0.7.0): keys of the counter's rubric and whole counts, never a word of a turn.
62
+ if (behaviour && behaviour.signals && typeof behaviour.analyzer_version === "string" && /^counter@\d+\.\d+\.\d+$/.test(behaviour.analyzer_version)) {
63
+ const kept = Object.entries(behaviour.signals).filter(([key, count]) => SIGNAL.test(key) && Number.isInteger(count) && count > 0).sort();
64
+ if (kept.length > 0) { line.signals = Object.fromEntries(kept); line.analyzer_version = behaviour.analyzer_version; }
65
+ }
66
+ return { ...line, stretch_ref: entry.stretch_ref, ...ids, collector_version: collector };
67
+ }
68
+
@@ -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.1";
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");