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 +2 -0
- package/log-session.mjs +87 -3
- package/package.json +2 -1
- package/preserve-lines.mjs +73 -0
- package/preserve-summary.mjs +4 -1
- package/preserve.mjs +3 -46
- package/worktrust.mjs +1 -1
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
|
-
|
|
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.
|
|
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
|
+
|
package/preserve-summary.mjs
CHANGED
|
@@ -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.
|
|
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");
|