worktrust 0.6.17 → 0.7.0
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 +5 -2
- package/count-behaviour.mjs +29 -3
- package/log-session.mjs +54 -13
- package/package.json +1 -1
- package/preserve.mjs +50 -14
- package/worktrust.mjs +3 -2
package/README.md
CHANGED
|
@@ -145,8 +145,10 @@ nothing it does not:
|
|
|
145
145
|
| `day`, `started_at`, `ended_at` | the day, and when the measured stretch began and ended |
|
|
146
146
|
| `seconds` | measured time: every gap between stamped events up to five minutes |
|
|
147
147
|
| `model_seconds`, `tool_seconds`, `human_seconds`, `idle_seconds` | what those seconds were: the model answering, tools or an agent run, your own turns, and what a cap cut off |
|
|
148
|
-
| `tokens_in`, `tokens_out`, `tokens_cache_read`, `tokens_cache_write` | token counts, when the app records them |
|
|
148
|
+
| `tokens_in`, `tokens_out`, `tokens_cache_read`, `tokens_cache_write` | token counts, when the app records them (one answer counted once, however many lines the app wrote it in) |
|
|
149
149
|
| `agent_runs`, `agent_seconds`, `agent_peak` | subagents: how many ran, their own time, the most open at once (never added to `seconds`) |
|
|
150
|
+
| `interrupts`, `steers` | how often you stopped the agent mid-answer, and how often your message arrived while it was still mid-task (counts, never the words) |
|
|
151
|
+
| `utc_offset` | your computer's offset from UTC when the stretch began, such as +02:00, so a working day reads in your own local time |
|
|
150
152
|
| `model` | the model's name as the app reported it |
|
|
151
153
|
| `kind`, `layer`, `layers` | the kind of work (built, changed, researched) and the area it touched (front end, back end, data …), as the session hook names them |
|
|
152
154
|
| `exchanges` | how many times you and the model took turns |
|
|
@@ -156,6 +158,7 @@ nothing it does not:
|
|
|
156
158
|
| `project` | a one-way hash of the project's name: which days belong together, never which project |
|
|
157
159
|
| `stretch_ref` | a hash of the session's id and the day, so a stretch is archived once |
|
|
158
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
|
+
| `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) |
|
|
159
162
|
| `collector_version` | the CLI version that measured it |
|
|
160
163
|
| `seq`, `prev`, `hash` | the line's place, the hash of the line before it, and its own hash |
|
|
161
164
|
|
|
@@ -237,7 +240,7 @@ which would delete every password on the computer, and many people rightly keep
|
|
|
237
240
|
| **Reads** | Which AI apps are installed (the settings folders of Claude Code, Codex, Cursor, Gemini CLI, Antigravity, VS Code, Windsurf, Hermes Agent, Goose, OpenCode, OpenClaw), and the chats Cursor and Copilot keep (clocks, models and token counts only). Afterwards, the session hook reads Claude Code's, Codex's and Antigravity's session files, the session databases of Hermes, Goose, OpenCode and OpenClaw, and the chats Cursor and Copilot keep, on this computer to measure durations and token counts. |
|
|
238
241
|
| **Writes** | The key file (above). In each AI app's MCP settings (`~/.claude.json` through `claude mcp`, `~/.cursor/mcp.json`, `~/.codex/config.toml`, `~/.gemini/settings.json`, `~/.gemini/config/mcp_config.json` (Antigravity), VS Code's `mcp.json`, Windsurf's `mcp_config.json`, Hermes's and Goose's `config.yaml`, OpenCode's `opencode.json`, OpenClaw through `openclaw mcp set`) a WorkTrust entry that runs the local bridge, with no key in it; three hooks in `~/.claude/settings.json`; the bridge, the hook and the counter in `~/.worktrust/`. |
|
|
239
242
|
| **Sends, to pair** | This computer's system (macOS, Windows, Linux) and host name. WorkTrust also records the network address the request arrives from, as a security record kept 90 days and seen only by WorkTrust staff (to answer a theft or fraud report). |
|
|
240
|
-
| **Sends, afterwards** | Per measured stretch of work, and nothing else: its durations (`seconds`, `duration_basis`, `model_seconds`, `tool_seconds`, `human_seconds`, `idle_seconds`), token counts (`tokens_in`, `tokens_out`, `tokens_cache_read`, `tokens_cache_write`, `token_basis`), the model's name (`model`), the kind of work and one layer keyword such as frontend or backend (`title`, `kind`, `layer`, `layers`), counts of how you work (`exchanges`, `turn_basis`, `agent_runs`, `agent_seconds`, `agent_peak`), when it ended (`at`), a hash of the session and the day (`stretch_ref`), `steered_from` when it ran over SSH, and, so the work can be matched to GitHub, the repository as its remote's owner/name (`repo`) and the hashes of the commits made in the stretch (`commits`, never their messages). |
|
|
243
|
+
| **Sends, afterwards** | Per measured stretch of work, and nothing else: its durations (`seconds`, `duration_basis`, `model_seconds`, `tool_seconds`, `human_seconds`, `idle_seconds`), token counts (`tokens_in`, `tokens_out`, `tokens_cache_read`, `tokens_cache_write`, `token_basis`), the model's name (`model`), the kind of work and one layer keyword such as frontend or backend (`title`, `kind`, `layer`, `layers`), counts of how you work (`exchanges`, `turn_basis`, `agent_runs`, `agent_seconds`, `agent_peak`, `interrupts`, `steers`), when it began and ended and your computer's offset from UTC (`started_at`, `at`, `utc_offset`), a hash of the session and the day (`stretch_ref`), `steered_from` when it ran over SSH, and, so the work can be matched to GitHub, the repository as its remote's owner/name (`repo`) and the hashes of the commits made in the stretch (`commits`, never their messages). |
|
|
241
244
|
| **Never sends** | Prompts, answers, code, file names or paths, commit messages, branch names, or the name of your local folder (a repository is named only as its remote's owner/name, above). The door has no field for them and refuses a submission that carries them. |
|
|
242
245
|
|
|
243
246
|
## Why you can check it
|
package/count-behaviour.mjs
CHANGED
|
@@ -788,6 +788,18 @@ export const isoWeek = (day) => {
|
|
|
788
788
|
return `${thursday.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
|
|
789
789
|
};
|
|
790
790
|
const byWeek = new Map(); // "month|week" → Map(signal → count)
|
|
791
|
+
/**
|
|
792
|
+
* THE WORK STRETCH (0.7.0, the owner's capability framework v4 §13: "work_stretch_id, the stable unit joining AI
|
|
793
|
+
* activity, verification, failures and delivery"). The same observation is tallied a third time, per stretch: one
|
|
794
|
+
* session's UTC day, named by the hash the session hook files the stretch under (`stretch_ref` in log-session.mjs), so a
|
|
795
|
+
* signal and the stretch's clocks, tokens and commits meet on one key. Only sessions read from a file carry it (Claude
|
|
796
|
+
* Code, Codex, Antigravity); the session's id is hashed with the day and never leaves the computer. Read by `preserve`
|
|
797
|
+
* through --stretch-signals; nothing of it is sent yet.
|
|
798
|
+
*/
|
|
799
|
+
const byStretch = new Map(); // stretch_ref → { day, signals: { signal: count } }
|
|
800
|
+
let currentStretchBase = null; // the session's own id, as the session hook hashes it — local only
|
|
801
|
+
const stretchBaseOf = (session) => !session.path ? null : /(^|[\\/])brain[\\/][^\\/]+[\\/]\.system_generated[\\/]logs[\\/]transcript\.jsonl$/.test(session.path) ? String(session.path).split(/[\\/]/).at(-4) : String(session.path).split(/[\\/]/).pop().replace(/\.jsonl?$/, "");
|
|
802
|
+
const stretchRefOf = (base, day) => createHash("sha256").update(`worktrust-stretch|${base}|${day}`).digest("base64url");
|
|
791
803
|
const bump = (month, signal, n = 1) => {
|
|
792
804
|
{
|
|
793
805
|
const key = `${month}|${currentDay && currentDay.startsWith(month) ? isoWeek(currentDay) : "U"}`;
|
|
@@ -795,6 +807,11 @@ const bump = (month, signal, n = 1) => {
|
|
|
795
807
|
week.set(signal, (week.get(signal) ?? 0) + n);
|
|
796
808
|
byWeek.set(key, week);
|
|
797
809
|
}
|
|
810
|
+
if (currentStretchBase && currentDay) {
|
|
811
|
+
const ref = stretchRefOf(currentStretchBase, currentDay), entry = byStretch.get(ref) ?? { day: currentDay, signals: {} };
|
|
812
|
+
entry.signals[signal] = (entry.signals[signal] ?? 0) + n;
|
|
813
|
+
byStretch.set(ref, entry);
|
|
814
|
+
}
|
|
798
815
|
if (currentUnit) {
|
|
799
816
|
const bag = unitsByMonth.get(month) ?? new Map();
|
|
800
817
|
bag.set(`${currentUnit}|${signal}`, { unit: currentUnit, signal, induced: currentInduced, day: currentDay, context: currentContext });
|
|
@@ -827,10 +844,10 @@ const seenLines = new Set(); // v0.10: line identities already counted (a uuid o
|
|
|
827
844
|
const manifest = [];
|
|
828
845
|
for(const session of sessions())manifest.push(sessionDigest(session));
|
|
829
846
|
exportConversations = 0; copilotSessionCount = 0; codexRolloutCount = 0; antigravityCount = 0; skippedDirs.length = 0; countedDirs.length = 0; skippedConversations.length = 0;
|
|
830
|
-
const checkpoint = flag("no-checkpoint") || LABEL_OUT ? null : checkpointStore(
|
|
847
|
+
const checkpoint = flag("no-checkpoint") || flag("stretch-signals") || LABEL_OUT ? null : checkpointStore(
|
|
831
848
|
value("checkpoint") ?? join(homedir(), ".worktrust", "counter-checkpoint.json"),
|
|
832
849
|
fingerprint({code:readFileSync(new URL(import.meta.url)),version:ANALYZER_VERSION,months:MONTHS_BACK,thisMonth,exclude:EXCLUDE,leaveOut:LEAVE_OUT,exports:EXPORTS}),manifest);
|
|
833
|
-
const checkpointMaps = {byMonth,byWeek,unitsByMonth,contextNames,contextBasenames,byMonthContext,monthToolNames,modelFirstSeen,monthMcpServers,rolePatterns,contextCwds,projectFirstSeen,sessionsByMonth};
|
|
850
|
+
const checkpointMaps = {byMonth,byWeek,byStretch,unitsByMonth,contextNames,contextBasenames,byMonthContext,monthToolNames,modelFirstSeen,monthMcpServers,rolePatterns,contextCwds,projectFirstSeen,sessionsByMonth};
|
|
834
851
|
const checkpointSets = {openingHashes,toolFirstSeen,seenLines};
|
|
835
852
|
if (checkpoint?.saved) {
|
|
836
853
|
for(const [key,map] of Object.entries(checkpointMaps)) for(const [k,v] of checkpoint.saved.state.maps[key] ?? []) map.set(k,v);
|
|
@@ -849,6 +866,7 @@ for (const session of sessions()) {
|
|
|
849
866
|
if(sessionIndex++<resumeAt)continue;
|
|
850
867
|
const streamHash = session.path ? createHash("sha256").update(`file:${session.path}\n`) : null;
|
|
851
868
|
const file = session.key;
|
|
869
|
+
currentStretchBase = stretchBaseOf(session);
|
|
852
870
|
files += 1;
|
|
853
871
|
// One file is one session: order preserved, so the STRUCTURAL half reads the shape.
|
|
854
872
|
let sessionMonth = null;
|
|
@@ -1152,7 +1170,7 @@ for (const session of sessions()) {
|
|
|
1152
1170
|
checkpoint?.close();process.exit(0);
|
|
1153
1171
|
}
|
|
1154
1172
|
}
|
|
1155
|
-
currentUnit = null; currentDay = null; // month-level aggregates carry no unit and no
|
|
1173
|
+
currentUnit = null; currentDay = null; currentStretchBase = null; // month-level aggregates carry no unit, no day and no stretch
|
|
1156
1174
|
for (const [month, set] of monthToolNames) bump(month, "toolBreadth", set.size);
|
|
1157
1175
|
for (const [month, set] of monthMcpServers) if (set.size > 0) bump(month, "mcpServersDistinct", set.size);
|
|
1158
1176
|
for (const [month, byPattern] of rolePatterns) for (const sessions of byPattern.values()) if (sessions.size >= RECURRENCE_FLOOR) bump(month, "recurringPatternCandidate");
|
|
@@ -1190,6 +1208,14 @@ for (let i = 0; i < sessionSpans.length; i += 1) {
|
|
|
1190
1208
|
if (overlapping.has(i)) { currentUnit = a.unit; currentDay = a.day ?? null; currentContext = a.context ?? null; bump(a.month, "parallelSessions"); currentUnit = null; currentDay = null; currentContext = null; }
|
|
1191
1209
|
}
|
|
1192
1210
|
|
|
1211
|
+
// THE STRETCHES, FOR PRESERVE (0.7.0): one JSON line per stretch that carries a signal, the analyzer's version on each,
|
|
1212
|
+
// and a closing count so the reader can tell a whole answer from a cut one. Counts and keys only; nothing is sent.
|
|
1213
|
+
if (flag("stretch-signals")) {
|
|
1214
|
+
const rows = [...byStretch.entries()].sort(([, a], [, b]) => a.day.localeCompare(b.day)).map(([stretch_ref, entry]) => JSON.stringify({ stretch_ref, day: entry.day, analyzer_version: ANALYZER_VERSION, signals: Object.fromEntries(Object.entries(entry.signals).sort().map(([signal, count]) => [signal, Math.min(COUNT_CEILING, count)])) }));
|
|
1215
|
+
await new Promise((resolve) => process.stdout.write(`${[...rows, JSON.stringify({ stretch_signals_end: rows.length })].join("\n")}\n`, resolve));
|
|
1216
|
+
process.exit(0);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1193
1219
|
const months = [...byMonth.keys()].sort();
|
|
1194
1220
|
console.log(`${ANALYZER_VERSION} · ${files} transcript file(s), ${messages} own messages, ${months.length} month(s) with counts`);
|
|
1195
1221
|
console.log(` projects counted: ${countedDirs.length}${countedDirs.length ? ` (${countedDirs.join(", ")})` : ""}`);
|
package/log-session.mjs
CHANGED
|
@@ -342,22 +342,45 @@ function stretchesOf(file, since) {
|
|
|
342
342
|
}
|
|
343
343
|
if (messages.length === 0) return [];
|
|
344
344
|
messages.sort((a, b) => a.at - b.at);
|
|
345
|
+
const byDay = byDayOf(messages);
|
|
346
|
+
const agents = agentsByDay(file, since);
|
|
347
|
+
return [...byDay.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([day, ofDay]) => { const stretch = measure(cwd, ofDay); return stretch && { ...stretch, ref: stretchRef(file, day), steered, agents: agents.get(day) ?? null }; }).filter(Boolean);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* DAYS SPLIT ON THE CLOCK, NOTHING LOST AT MIDNIGHT (0.6.18, audit C14). A stretch is one session's UTC day, and the gap
|
|
352
|
+
* between a day's last message and the next day's first belonged to neither, so a run through the night lost one gap
|
|
353
|
+
* per midnight. A day whose first message follows the last one within the tool cap now opens with a BRIDGE: the message
|
|
354
|
+
* before it, carrying no tokens, no model and no turn, so the gap that crosses midnight is counted once, on the day it
|
|
355
|
+
* ends, under the same caps as any other.
|
|
356
|
+
* Messages must arrive sorted.
|
|
357
|
+
*/
|
|
358
|
+
function byDayOf(messages) {
|
|
345
359
|
const byDay = new Map();
|
|
360
|
+
let prev = null;
|
|
346
361
|
for (const message of messages) {
|
|
347
362
|
const day = new Date(message.at).toISOString().slice(0, 10);
|
|
348
|
-
|
|
363
|
+
// Only a gap the work could have run through (within the tool cap): a session resumed the next morning opens its day as it always did.
|
|
364
|
+
if (!byDay.has(day)) byDay.set(day, prev && message.at - prev.at <= TOOL_CAP * 1000 ? [{ ...prev, usage: null, model: null, cumulative: false, bridge: true }] : []);
|
|
349
365
|
byDay.get(day).push(message);
|
|
366
|
+
prev = message;
|
|
350
367
|
}
|
|
351
|
-
|
|
352
|
-
return [...byDay.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([day, ofDay]) => { const stretch = measure(cwd, ofDay); return stretch && { ...stretch, ref: stretchRef(file, day), steered, agents: agents.get(day) ?? null }; }).filter(Boolean);
|
|
368
|
+
return byDay;
|
|
353
369
|
}
|
|
370
|
+
/** The first message a day really holds (a bridge is the day before's). */
|
|
371
|
+
const firstOf = (messages) => (messages.find((message) => !message.bridge) ?? messages[0]).at;
|
|
372
|
+
/** The computer's offset from UTC at a moment, as +02:00: what the owner's mirror needs to read a working day in local time. */
|
|
373
|
+
const offsetAt = (ms) => { const minutes = -new Date(ms).getTimezoneOffset(), abs = Math.abs(minutes); return `${minutes < 0 ? "-" : "+"}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`; };
|
|
374
|
+
/** Claude Code's own line when the person stops the agent mid-answer (Esc), read for its marker only. */
|
|
375
|
+
const INTERRUPTED = /^\[Request interrupted by user/;
|
|
376
|
+
const textOf = (content) => (typeof content === "string" ? content : Array.isArray(content) ? content.map((block) => (block && typeof block.text === "string" ? block.text : "")).join("") : "");
|
|
354
377
|
|
|
355
378
|
/** One transcript line as the measure reads it: the clock, the role, what KIND of message it is (for the layers), its counts. */
|
|
356
379
|
const messageOf = (line, at) => {
|
|
357
380
|
const content = line.message?.content ?? null;
|
|
358
381
|
const blocks = Array.isArray(content) ? content : [];
|
|
359
382
|
return {
|
|
360
|
-
at, type: line.type, usage: line.message?.usage ?? null, model: line.message?.model ?? null, meta: line.isMeta === true, content,
|
|
383
|
+
at, type: line.type, usage: line.message?.usage ?? null, model: line.message?.model ?? null, messageId: typeof line.message?.id === "string" ? line.message.id : null, meta: line.isMeta === true, content,
|
|
361
384
|
// A tool's answer comes back on the user role; a line a reader marks `agentic` is an agent run (Copilot's asked → completed).
|
|
362
385
|
kind: line.type === "assistant" ? "assistant" : blocks.some((block) => block && block.type === "tool_result") ? "tool_result" : "user",
|
|
363
386
|
toolUse: line.type === "assistant" && blocks.some((block) => block && block.type === "tool_use"),
|
|
@@ -456,12 +479,22 @@ function measure(cwd, messages) {
|
|
|
456
479
|
const models = new Map();
|
|
457
480
|
let exchanges = 0;
|
|
458
481
|
for (const message of messages) {
|
|
459
|
-
if (message.type === "user" && !message.meta && isHumanTurn(message.content)) exchanges += 1;
|
|
482
|
+
if (message.type === "user" && !message.meta && !message.bridge && isHumanTurn(message.content)) exchanges += 1;
|
|
460
483
|
if (message.model) models.set(message.model, (models.get(message.model) ?? 0) + 1);
|
|
461
484
|
}
|
|
462
485
|
const model = [...models.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
|
|
463
486
|
const tokens = tokensOf(messages);
|
|
464
|
-
|
|
487
|
+
// HOW THE PERSON STEERED (owner, 2026-10-08): how often they stopped the agent (Claude Code's interruption line), and how
|
|
488
|
+
// often their next turn arrived while the agent was still mid-task (right after a tool call or its result). Counts only.
|
|
489
|
+
let interrupts = 0, steers = 0;
|
|
490
|
+
for (let i = 1; i < messages.length; i += 1) {
|
|
491
|
+
const message = messages[i], prev = messages[i - 1];
|
|
492
|
+
if (message.bridge || message.type !== "user" || message.meta || message.kind === "tool_result") continue;
|
|
493
|
+
if (INTERRUPTED.test(textOf(message.content).trim())) interrupts += 1;
|
|
494
|
+
else if (isHumanTurn(message.content) && (prev.kind === "tool_result" || prev.toolUse)) steers += 1;
|
|
495
|
+
}
|
|
496
|
+
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) };
|
|
465
498
|
}
|
|
466
499
|
|
|
467
500
|
/**
|
|
@@ -508,9 +541,14 @@ function usageShapeKnown(usage) {
|
|
|
508
541
|
}
|
|
509
542
|
function tokensOf(messages) {
|
|
510
543
|
let tokensIn = 0, tokensOut = 0, cacheRead = 0, cacheWrite = 0, counted = 0, shapeKnown = true;
|
|
544
|
+
// ONE ANSWER, ONE USAGE (0.6.18): Claude Code writes an answer as a line per content block (thinking, text, each tool
|
|
545
|
+
// call), every one repeating the answer's usage; summed per line, a real session read 2.6 times its output tokens. An
|
|
546
|
+
// answer with an id is counted once; a line without one (Codex's token events) is its own.
|
|
547
|
+
const seen = new Set();
|
|
511
548
|
for (const message of messages) {
|
|
512
549
|
const usage = message.usage;
|
|
513
550
|
if (!usage) continue;
|
|
551
|
+
if (message.messageId) { if (seen.has(message.messageId)) continue; seen.add(message.messageId); }
|
|
514
552
|
if (!usageShapeKnown(usage)) { shapeKnown = false; break; }
|
|
515
553
|
counted += 1;
|
|
516
554
|
tokensIn += (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
|
|
@@ -565,6 +603,10 @@ function payloadFor(stretch) {
|
|
|
565
603
|
// THE LAYERS (2026-10-06), only on a measured stretch, each bounded by a day: what the seconds were. `seconds` above is untouched.
|
|
566
604
|
...(stretch.seconds !== null && stretch.layers ? { model_seconds: Math.min(DAY_SECONDS, stretch.layers.model), tool_seconds: Math.min(DAY_SECONDS, stretch.layers.tool), human_seconds: Math.min(DAY_SECONDS, stretch.layers.human), idle_seconds: Math.min(DAY_SECONDS, stretch.layers.idle) } : {}),
|
|
567
605
|
// THE SUBAGENTS, as agent metadata beside the day: runs, their own seconds, the peak open at once. Never in `seconds`.
|
|
606
|
+
// WHEN AND HOW (0.6.18): the stretch's first moment and the computer's UTC offset then; the person's interruptions and mid-task turns, counts only.
|
|
607
|
+
started_at: new Date(stretch.from).toISOString(), utc_offset: stretch.offset,
|
|
608
|
+
...(stretch.interrupts > 0 ? { interrupts: Math.min(10000, stretch.interrupts) } : {}),
|
|
609
|
+
...(stretch.steers > 0 ? { steers: Math.min(10000, stretch.steers) } : {}),
|
|
568
610
|
...(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) } : {}),
|
|
569
611
|
at: new Date(stretch.to).toISOString(),
|
|
570
612
|
};
|
|
@@ -643,23 +685,22 @@ function historyEntries(floor = null, all = false) {
|
|
|
643
685
|
const lines = parsedLines(file);
|
|
644
686
|
if (!lines) continue;
|
|
645
687
|
let cwd = null, steered = null;
|
|
646
|
-
const
|
|
688
|
+
const read = [];
|
|
647
689
|
for (const line of lines) {
|
|
648
690
|
if (typeof line.cwd === "string") cwd = line.cwd;
|
|
649
691
|
if (typeof line.steered === "string") steered = line.steered;
|
|
650
692
|
if (!line.timestamp || (line.type !== "user" && line.type !== "assistant")) continue;
|
|
651
693
|
const at = Date.parse(line.timestamp);
|
|
652
694
|
if (!Number.isFinite(at)) continue;
|
|
653
|
-
|
|
654
|
-
if (!byDay.has(day)) byDay.set(day, []);
|
|
655
|
-
byDay.get(day).push(messageOf(line, at));
|
|
695
|
+
read.push(messageOf(line, at));
|
|
656
696
|
}
|
|
697
|
+
read.sort((a, b) => a.at - b.at);
|
|
698
|
+
const byDay = byDayOf(read);
|
|
657
699
|
const agents = agentsByDay(file, null);
|
|
658
700
|
for (const [day, messages] of byDay) {
|
|
659
701
|
if (messages.length === 0) continue;
|
|
660
|
-
messages.sort((a, b) => a.at - b.at);
|
|
661
702
|
// Under-reporting is the safe side (THE FLOORS): a day that began before the account changed is not offered at all.
|
|
662
|
-
if (floor !== null && messages
|
|
703
|
+
if (floor !== null && firstOf(messages) < floor) continue;
|
|
663
704
|
const last = messages[messages.length - 1].at;
|
|
664
705
|
// The live window belongs to the hook: a stretch that log_work may still report is not history.
|
|
665
706
|
if (!all && Date.now() - last < WINDOW_HOURS * 3600_000) continue;
|
|
@@ -668,7 +709,7 @@ function historyEntries(floor = null, all = false) {
|
|
|
668
709
|
if (CODEX_ROLLOUT.test(file)) entrySource.set(entry, "codex");
|
|
669
710
|
if (ANTIGRAVITY_TRANSCRIPT.test(file)) entrySource.set(entry, "antigravity");
|
|
670
711
|
if (DATABASE_SESSION.test(file)) entrySource.set(entry, file.slice(0, file.indexOf(":")));
|
|
671
|
-
entryFile.set(entry, { file, from: messages
|
|
712
|
+
entryFile.set(entry, { file, from: firstOf(messages), folder: cwd ? String(cwd).split(/[\\/]/).filter(Boolean).pop() ?? null : null });
|
|
672
713
|
entries.push(entry);
|
|
673
714
|
}
|
|
674
715
|
}
|
package/package.json
CHANGED
package/preserve.mjs
CHANGED
|
@@ -168,7 +168,7 @@ async function apply(list, agreed = false) {
|
|
|
168
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"];
|
|
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
172
|
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
|
|
173
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
174
|
const WORDS = ["layer", "kind", "steered_from", "duration_basis", "token_basis", "turn_basis"];
|
|
@@ -187,7 +187,22 @@ function computerIds() {
|
|
|
187
187
|
const hash = (text) => createHash("sha256").update(text).digest("base64url");
|
|
188
188
|
return { device_id: hash(`worktrust-machine|${machine.toLowerCase()}`), profile_id: hash(`worktrust-profile|${machine.toLowerCase()}|${homedir()}`) };
|
|
189
189
|
}
|
|
190
|
-
|
|
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) {
|
|
191
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;
|
|
192
207
|
const line = { client: entry.client, day: entry.started_at.slice(0, 10), started_at: entry.started_at, ended_at: entry.at };
|
|
193
208
|
for (const key of COUNTS) if (typeof entry[key] === "number" && Number.isFinite(entry[key]) && entry[key] >= 0) line[key] = entry[key];
|
|
@@ -195,11 +210,18 @@ function archiveLine(entry) {
|
|
|
195
210
|
for (const key of WORDS) if (typeof entry[key] === "string" && WORD.test(entry[key])) line[key] = entry[key];
|
|
196
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);
|
|
197
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;
|
|
198
215
|
// Commit hashes the stretch made, never a message: what ties AI time to delivered work in the repository.
|
|
199
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);
|
|
200
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.
|
|
201
218
|
const name = entry.repo ?? entry.folder;
|
|
202
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
|
+
}
|
|
203
225
|
return { ...line, stretch_ref: entry.stretch_ref, ...IDS, collector_version: COLLECTOR };
|
|
204
226
|
}
|
|
205
227
|
|
|
@@ -244,6 +266,8 @@ function check(dir) {
|
|
|
244
266
|
}
|
|
245
267
|
const manifest = readJson(join(dir, "manifest.json")).value;
|
|
246
268
|
if (rows.length > 0 && (!manifest || manifest.count !== lines.length || manifest.head !== lines.at(-1).hash)) return broken("manifest.json", "its count or head does not match the lines (lines were removed or added outside preserve)");
|
|
269
|
+
// THE MANIFEST'S SEAL (0.6.18, audit C9): a signed count and head, so a cut tail with a manifest edited to match is caught.
|
|
270
|
+
if (manifest && typeof manifest.signature === "string" && !signedBy(manifest)) return broken("manifest.json", "its signature does not verify with the key named in it (the count or the last line was changed)");
|
|
247
271
|
const days = new Map();
|
|
248
272
|
for (const line of lines) days.set(line.day, [...(days.get(line.day) ?? []), line]);
|
|
249
273
|
const mine = deviceKey()?.publicX ?? null;
|
|
@@ -255,15 +279,19 @@ function check(dir) {
|
|
|
255
279
|
if (!proof || proof.date !== day) return broken(where, "missing or unreadable");
|
|
256
280
|
if (proof.count !== ofDay.length || proof.root !== rootOf(ofDay.map((line) => line.hash))) return broken(where, "the day's root does not match its lines");
|
|
257
281
|
if (proof.signature === null) continue;
|
|
258
|
-
|
|
259
|
-
let valid = false;
|
|
260
|
-
try { valid = verify(null, Buffer.from(canonical(body)), createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: body.public_key }, format: "jwk" }), Buffer.from(signature, "base64url")); } catch { valid = false; }
|
|
261
|
-
if (!valid) return broken(where, "the signature does not verify with the key named in it");
|
|
282
|
+
if (!signedBy(proof)) return broken(where, "the signature does not verify with the key named in it");
|
|
262
283
|
signed += 1;
|
|
263
|
-
if (
|
|
284
|
+
if (proof.public_key === mine) ours += 1;
|
|
264
285
|
}
|
|
265
|
-
return { lines, days, signed, ours, manifest };
|
|
286
|
+
return { lines, days, signed, ours, manifest, proofs: new Map([...days.keys()].map((day) => [day, readJson(join(dir, "proofs", `${day}.json`)).value])) };
|
|
287
|
+
}
|
|
288
|
+
/** Whether a signed body (a day's proof, the manifest) verifies under the key it names. */
|
|
289
|
+
function signedBy(document) {
|
|
290
|
+
const { signature, ...body } = document;
|
|
291
|
+
try { return verify(null, Buffer.from(canonical(body)), createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: body.public_key }, format: "jwk" }), Buffer.from(signature, "base64url")); } catch { return false; }
|
|
266
292
|
}
|
|
293
|
+
/** Signed with this computer's key, or null: the body as it is signed. */
|
|
294
|
+
const sealed = (body, key) => ({ ...body, signature: key ? sign(null, Buffer.from(canonical(body)), key.privateKey).toString("base64url") : null });
|
|
267
295
|
|
|
268
296
|
function archive(dir) {
|
|
269
297
|
IDS = computerIds();
|
|
@@ -274,11 +302,12 @@ function archive(dir) {
|
|
|
274
302
|
// 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.
|
|
275
303
|
const rows = hook.stdout.split("\n").filter(Boolean), end = (() => { try { return JSON.parse(rows.at(-1) ?? "").archive_end; } catch { return undefined; } })();
|
|
276
304
|
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();
|
|
277
306
|
const known = new Set(existing.lines.map((line) => line.stretch_ref)), fresh = [], measured = new Set();
|
|
278
307
|
let waiting = 0;
|
|
279
308
|
for (const text of rows.slice(0, -1)) {
|
|
280
309
|
let entry; try { entry = JSON.parse(text); } catch { continue; }
|
|
281
|
-
const line = archiveLine(entry);
|
|
310
|
+
const line = archiveLine(entry, signals?.get(entry.stretch_ref) ?? null);
|
|
282
311
|
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
|
|
283
312
|
if (line.day >= today()) { waiting += 1; continue; }
|
|
284
313
|
known.add(line.stretch_ref);
|
|
@@ -297,13 +326,19 @@ function archive(dir) {
|
|
|
297
326
|
prev = hashed.hash;
|
|
298
327
|
}
|
|
299
328
|
// A day that gained lines gets its root again over all of its lines; the lines themselves are never rewritten.
|
|
329
|
+
// THIS KEY SIGNS ONLY ITS OWN LINES (0.6.18, audit S7): a day is signed when every earlier line of it was already under
|
|
330
|
+
// this key's signature (or there were none); a day that came into the folder from elsewhere keeps an unsigned root.
|
|
300
331
|
const key = deviceKey();
|
|
332
|
+
const ours = (document) => !document || (key && typeof document.signature === "string" && document.public_key === key.publicX);
|
|
301
333
|
mkdirSync(join(dir, "proofs"), { recursive: true });
|
|
302
334
|
for (const day of new Set(fresh.map((line) => line.day))) {
|
|
303
|
-
const
|
|
304
|
-
|
|
335
|
+
const own = !existing.days?.has(day) || ours(existing.proofs?.get(day));
|
|
336
|
+
const body = proofBody(day, all.filter((line) => line.day === day), own ? key : null);
|
|
337
|
+
writeFileSync(join(dir, "proofs", `${day}.json`), `${JSON.stringify(sealed(body, own ? key : null), null, 1)}\n`);
|
|
305
338
|
}
|
|
306
|
-
|
|
339
|
+
// The manifest is sealed the same way: a new archive, or one whose manifest this key already signed.
|
|
340
|
+
const manifestKey = !existing.manifest || ours(existing.manifest) ? key : null;
|
|
341
|
+
if (fresh.length > 0 || !existing.manifest) writeFileSync(join(dir, "manifest.json"), `${JSON.stringify(sealed({ format: "worktrust-ai-evidence/1", collector_version: COLLECTOR, created: existing.manifest?.created ?? new Date().toISOString(), updated: new Date().toISOString(), clients: [...new Set(all.map((line) => line.client))].sort(), ...IDS, count: all.length, head: prev, ...(manifestKey ? { public_key: manifestKey.publicX } : {}) }, manifestKey), null, 1)}\n`);
|
|
307
342
|
say();
|
|
308
343
|
say(` ✓ ${fresh.length} new day-stretch${fresh.length === 1 ? "" : "es"} archived in ${dir}: it holds ${all.length}, the session hook measures ${measured.size} finished ones here now${key ? ", each day's root signed with this computer's device key" : ", unsigned (no device key on this computer)"}.`);
|
|
309
344
|
if (waiting > 0) say(` ${waiting} of today wait for tomorrow's run: a day is archived once it is over.`);
|
|
@@ -314,8 +349,9 @@ function verifyArchive(dir) {
|
|
|
314
349
|
const result = check(dir);
|
|
315
350
|
if (result.broken) { say(` BROKEN ${result.broken}`); process.exit(1); }
|
|
316
351
|
if (result.lines.length === 0) { say(` Nothing to verify in ${dir}.`); return; }
|
|
317
|
-
const
|
|
318
|
-
|
|
352
|
+
const daySeal = result.signed === 0 ? "no day signed" : result.ours === result.signed ? `${result.signed} signed by this computer's key` : `${result.signed} signed (${result.ours} by this computer's key, ${result.signed - result.ours} by another key named in them)`;
|
|
353
|
+
const seal = typeof result.manifest?.signature !== "string" ? "manifest unsigned" : result.manifest.public_key === deviceKey()?.publicX ? "manifest signed by this computer's key" : "manifest signed by another key named in it";
|
|
354
|
+
say(` OK ${result.lines.length} stretches · ${result.days.size} days · the chain is whole · every day's root matches · ${daySeal} · ${seal}`);
|
|
319
355
|
}
|
|
320
356
|
|
|
321
357
|
/** --summary: what a person or a team reads from the archive, counted locally (preserve-summary.mjs) once the chain checks out. */
|
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.
|
|
74
|
+
const CLI_VERSION = "0.7.0";
|
|
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");
|
|
@@ -670,7 +670,8 @@ async function offerRebuild(previousVersion, paths, key) {
|
|
|
670
670
|
if (!previousVersion || previousVersion === CLI_VERSION) return;
|
|
671
671
|
say();
|
|
672
672
|
say(` This computer's earlier lines in WorkTrust were measured by worktrust ${previousVersion}. Version ${CLI_VERSION} measures`);
|
|
673
|
-
say(" differently (
|
|
673
|
+
say(" differently (each answer's tokens counted once, nothing lost at midnight, when each stretch began, how you steered");
|
|
674
|
+
say(" the agent, what the seconds were, more AI apps). You can re-measure the");
|
|
674
675
|
say(" sessions still on this computer and have WorkTrust replace this computer's earlier lines, day by day, never counting");
|
|
675
676
|
say(" a day twice. Later: npx worktrust@latest history --rebuild");
|
|
676
677
|
if (!(await ask("Show the plan and rebuild now?", false))) { say(" Not now."); return; }
|