worktrust 0.0.0-stage → 0.5.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.
@@ -0,0 +1,1430 @@
1
+ /**
2
+ * THE LOCAL COUNTER — rubric v0.1 (`counter@0.1.0`).
3
+ *
4
+ * Reads this machine's own Claude Code transcripts, its Codex rollouts (2026-09-27), its VS Code
5
+ * Copilot chat sessions (2026-09-08) and, when given, a claude.ai export (~/.claude/projects/**​/*.jsonl — transcripts
6
+ * ONLY, never project files), classifies each of the person's OWN messages with the
7
+ * deterministic rules below, and reports COUNTS per month through the WorkTrust MCP door's
8
+ * `log_signals`. The same logs under the same rubric give the same numbers: every count is
9
+ * re-derivable, and a better rubric is a new version that writes beside this one.
10
+ *
11
+ * WHAT CANNOT LEAVE: the door's schema takes vocabulary keys and integers; it has no text
12
+ * field and refuses unknown keys by name. This script builds exactly that payload and nothing
13
+ * else — a password or a .env line has no channel to travel through. Run with --dry-run to see
14
+ * every number before anything is sent.
15
+ *
16
+ * node count-behaviour.mjs --dry-run
17
+ * node count-behaviour.mjs --url https://app.worktrust.io/api/mcp --token wt_...
18
+ * node count-behaviour.mjs --dry-run --exclude client-x --exclude secret
19
+ * node count-behaviour.mjs --dry-run --claude-export ~/Downloads/claude-export (a claude.ai data export beside the transcripts)
20
+ *
21
+ * CONFIDENTIAL PROJECTS stay out BEFORE anything is counted: `--exclude <text>` (repeatable)
22
+ * skips every project directory whose name contains that text, and `.worktrust-counter.json`
23
+ * in your home directory does the same permanently: { "exclude": ["client-x"] }. An excluded
24
+ * project is never read at all — there is nothing to erase later, because nothing left.
25
+ *
26
+ * RUBRIC v0.2 — a hypothesis, on purpose. v0.1 counted WORDS per message; v0.2 also counts
27
+ * STRUCTURE per session: follow-up questions after an answer, sequences opened with an edge
28
+ * case, work delegated to subagents (isSidechain / Agent tool_use), tools and skills invoked,
29
+ * autonomous permission modes, and long uninterrupted runs — all read from fields the
30
+ * transcripts already carry, none inferred.
31
+ *
32
+ * RUBRIC (lexical half) — Only signals with crisp lexical or structural
33
+ * markers are counted; everything ambiguous stays uncounted rather than miscounted. Rules run
34
+ * per user message (both English and Dutch), case-insensitively, one count per message per
35
+ * signal. The behaviour model carries each signal's validity; nothing here is a score.
36
+ */
37
+ import { createHash, randomBytes, sign as cryptoSign } from "node:crypto";
38
+ import { execFileSync } from "node:child_process";
39
+ import { readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, renameSync, openSync, closeSync, unlinkSync, readSync } from "node:fs";
40
+ import { join, dirname } from "node:path";
41
+ import { homedir } from "node:os";
42
+
43
+ /** DEVICE PROOF (2026-10-03): through `worktrust hook` a bound computer hands over WORKTRUST_DEVICE_KEY; every call is then signed. */
44
+ const proofFor = (url, body) => {
45
+ const device = process.env.WORKTRUST_DEVICE_KEY;
46
+ if (!device) return {};
47
+ const seconds = String(Math.floor(Date.now() / 1000));
48
+ const nonce = randomBytes(18).toString("base64url");
49
+ return { "worktrust-proof": `${seconds}.${nonce}.${cryptoSign(null, Buffer.from(`POST\n${new URL(url).pathname}\n${seconds}\n${nonce}\n${createHash("sha256").update(body).digest("hex")}`), device).toString("base64url")}` };
50
+ };
51
+
52
+ export const fingerprint = value => createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(value)).digest('hex');
53
+ export const encode = value => JSON.stringify(value, (_, v) => v instanceof Map ? {$map:[...v]} : v instanceof Set ? {$set:[...v]} : v);
54
+ export const decode = value => JSON.parse(value, (_, v) => v?.$map ? new Map(v.$map) : v?.$set ? new Set(v.$set) : v);
55
+ export function checkpointStore(path, signature, manifest) {
56
+ mkdirSync(dirname(path), {recursive:true,mode:0o700});
57
+ // A process lock prevents two local runs from replacing each other's checkpoint.
58
+ const lock=path+'.lock';
59
+ try { const fd=openSync(lock,'wx',0o600);writeFileSync(fd,String(process.pid));closeSync(fd); }
60
+ catch(error) {
61
+ if(error.code!=='EEXIST') throw error;
62
+ const pid=Number(readFileSync(lock,'utf8'));
63
+ try {process.kill(pid,0);throw Error('Counter checkpoint is in use');}
64
+ catch(e) {if(e.code!=='ESRCH')throw e;}
65
+ unlinkSync(lock);return checkpointStore(path,signature,manifest);
66
+ }
67
+ const release=()=>{try{unlinkSync(lock)}catch{}};
68
+ process.once('exit',release);
69
+ let saved=null;
70
+ try {saved=decode(readFileSync(path,'utf8'));}catch(error){if(error.code!=='ENOENT')console.error('Checkpoint unreadable; recounting from sources.');}
71
+ const valid=saved?.signature===signature && saved.manifest?.slice(0,saved.next).every((x,i)=>x===manifest[i]) && saved.next<=manifest.length
72
+ && Object.entries(saved.dependencies??{}).every(([p,h])=>{try{return fingerprint(readFileSync(p))===h}catch{return false}});
73
+ return {
74
+ saved:valid?saved:null,
75
+ write(next,state,dependencies={}) {
76
+ const temp=path+'.'+process.pid+'.tmp';
77
+ writeFileSync(temp,encode({signature,manifest,next,state,dependencies}),{mode:0o600});renameSync(temp,path);
78
+ },
79
+ close(){process.removeListener('exit',release);release();},
80
+ };
81
+ }
82
+
83
+ export const ANALYZER_VERSION = "counter@0.10.0";
84
+
85
+ /** signal key (packages/core/src/behaviour.ts) → the v0.1 marker that counts it. */
86
+ export const RULES = [
87
+ { signal: "edgeCases", pattern: /\bedge[ -]?cases?\b|\bcorner[ -]?cases?\b|\brandgeval(len)?\b|\bedge[ -]?scenario/i },
88
+ { signal: "counterfactualQuestions", pattern: /\bwhat if\b|\bwat als\b|\bstel dat\b|\bwhat happens (if|when)\b/i },
89
+ { signal: "alternativesRequested", pattern: /\balternative(s)?\b|\bother (option|way|approach)\b|\bandere (optie|manier|aanpak)\b|\balternatie(f|ven)\b/i },
90
+ { signal: "assumptionsSurfaced", pattern: /\bassum(e|ption|ing)\b|\baanname(s)?\b|\bervan uit\b|\buitgangspunt\b/i },
91
+ { signal: "counterarguments", pattern: /\bdisagree\b|\b(that|this)('s| is) (not |in)correct\b|\bklopt niet\b|\bniet mee eens\b|\bdat is fout\b|\bwhy not\b|\bwaarom niet\b/i },
92
+ { signal: "verificationAttempts", pattern: /\bverif(y|ieer|icatie)\b|\bcontroleer\b|\bcheck (dat|of|whether|that|it)\b|\brun (the )?tests?\b|\bdraai de tests?\b|\bprove\b|\bbewijs\b|\bmeet\b|\bmeasure\b/i },
93
+ { signal: "criteriaStated", pattern: /\bcriteri(a|um)\b|\bacceptance\b|\bmoet voldoen aan\b|\beisen?:\b|\brequirements?:\b|\bdefinition of done\b/i },
94
+ { signal: "tradeOffsNamed", pattern: /\btrade[ -]?offs?\b|\bafweging(en)?\b|\bvoor- en nadelen\b|\bpros and cons\b|\bnadeel is\b|\bdownside\b/i },
95
+ { signal: "constraintsDefined", pattern: /\bconstraints?\b|\bbinnen \d|\bmax(imum|imaal)?\b.*\b(regels|lines|tokens|uur|hours|mb|kb)\b|\bzonder (dat|een)\b|\bmust not\b|\bmag niet\b|\bnooit\b|\bnever\b/i },
96
+ { signal: "gapQuestions", pattern: /\bwhat('s| is) missing\b|\bwat (mist|ontbreekt)\b|\bis er iets vergeten\b|\banything (i|we) missed\b|\bwat zie ik over het hoofd\b/i },
97
+ { signal: "postMortemReflection", pattern: /\bwhy did(n't| not)? (it|that|this)\b|\bwaarom (werkte|ging|lukte|faalde)\b|\bwat ging (er )?(mis|fout)\b|\bwhat went wrong\b|\broot cause\b/i },
98
+ { signal: "problemReframed", pattern: /\bactually,? the (real|better) question\b|\beigenlijk is de vraag\b|\blaten we het anders (stellen|aanpakken)\b|\breframe\b|\bstep back\b|\been stap terug\b/i },
99
+ { signal: "prioritisation", pattern: /\beerst\b.*\bdaarna\b|\bfirst\b.*\bthen\b|\bprioritise|prioriteit\b|\bbelangrijkste eerst\b|\bin (this |deze )?volgorde\b|\bin (that |this )?order\b/i },
100
+ { signal: "evidenceDemands", pattern: /\bshow me\b|\bcite\b|\btoon aan\b|\bbewijs (dat|het)\b|\bwaar staat (dat|het)\b|\bprove (it|that)\b|\blink naar de docs\b/i },
101
+ { signal: "scopeCuts", pattern: /\bout of scope\b|\bbuiten scope\b|\blaat .{0,20}weg\b|\bniet nu\b|\bnot now\b|\bschrap\b|\bdrop (that|this|it)\b/i },
102
+ { signal: "decidedNotToBuild", pattern: /\b(we )?bouwen (dit|het) niet\b|\bdon'?t build\b|\bniet bouwen\b|\bbesloten .{0,20}niet te (bouwen|doen)\b|\bafblazen\b/i },
103
+ { signal: "measurementScepticism", pattern: /\bklopt (dit|dat) getal\b|\bis dit getal juist\b|\bcheck (the|that) (number|count)\b|\bare you sure th(is|ese|at) (number|count)/i },
104
+ { signal: "strategySwitch", pattern: /\bandere aanpak\b|\bdifferent approach\b|\bin plaats daarvan\b|\binstead,? let'?s\b|\blaten we overstappen\b|\bplan b\b/i },
105
+ { signal: "priorDecisionsCarried", pattern: /\bzoals (we|eerder) (besloten|afspraken|zeiden)\b|\bas (we )?(decided|agreed)\b|\bper (the|our) (adr|decision)\b|\bvolgens de afspraak\b/i },
106
+ { signal: "consistencyOverSession", pattern: /\bterug naar (het|de|ons)\b|\bback to (the|our)\b|\bfocus terug\b|\bstick to the plan\b/i },
107
+ // ── agency, as DECISION BEHAVIOUR only (framework 4.1, D4): what was said about a choice, never what was felt ──
108
+ { signal: "decisionAnnouncement", pattern: /\b(we gaan voor|ik ga voor|ik kies( voor)?|besloten:|beslissing:|i'?m going with|we'?re going with|i'?ll go with|let'?s go with|decision:|decided:|we choose|i choose)\b/i },
109
+ { signal: "permissionSeeking", pattern: /\b(mag ik|mogen we|is it ok(ay)? if|is it fine if|may i|can i go ahead|should i ask (permission|first)|zal ik eerst (vragen|overleggen)|moet ik (eerst )?toestemming)\b/i },
110
+ { signal: "decisionDeferral", pattern: /\b(let'?s postpone|postpone (the|this|that) decision|decide (that|this) later|we'?ll decide later|park (this|that|the) decision|beslissen we later|stel(len we)? (de|die|deze) beslissing uit|parkeren we (de|die|deze) (keuze|beslissing))\b/i },
111
+ { signal: "hypothesesRaised", pattern: /\bmy (hypothesis|theory|guess) is\b|\bmijn (hypothese|theorie|vermoeden) is\b|\bik (denk|vermoed) dat het komt door\b|\bi suspect\b|\bvolgens mij komt (het|dit) door\b/i },
112
+ ];
113
+
114
+ /**
115
+ * ── v0.8: THE FORM AN OPENING TAKES, deterministically (supplementary specification, chapter 8) ──
116
+ *
117
+ * Nine rules in priority order; the first that matches names the form. Nothing is guessed: a
118
+ * message no rule places is UNCLASSIFIED, and UNCLASSIFIED is counted. A form is not a quality —
119
+ * no form scores higher than another; only its association with what happened next, in the
120
+ * person's own record, says anything. Sent as `openingForm.<FORM>`, one count per session.
121
+ *
122
+ * ACCEPTANCE (proposed, chapter 8 and the measurement review): these fields are counter-grade
123
+ * only once two raters BLIND to the counter agree with it at Cohen's κ ≥ 0.6 per field per
124
+ * language class on ≥ 100 items, with UNCLASSIFIED under 10 %. Until then they are reading-grade
125
+ * and the screen says so. `--label-openings <file>` writes the openings with the counter's form
126
+ * beside them, LOCALLY, for the raters; `--agreement <file>` reads the labels back and reports κ.
127
+ */
128
+ export const OPENING_FORMS = ["ARTEFACT", "ROLE", "CONSTRAINT", "PROBLEM", "OUTCOME", "EXPLORE", "SOLUTION", "CONTINUE", "UNCLASSIFIED"];
129
+ export const SECOND_TURNS = ["ACCEPT", "CORRECT", "DEEPEN", "REDIRECT", "ABANDON", "UNCLASSIFIED"];
130
+ const ROLE_OPENING = /^\W*(je bent|jij bent|you are|you're|act as|als (een )?(senior|expert|ervaren)|gedraag je als|neem de rol|take the role)\b/i;
131
+ const CONSTRAINT_MARK = /\b(moet|mag niet|niet meer dan|binnen|zonder|alleen|must|must not|only|max(imum|imaal)?|no more than|never|nooit)\b/i;
132
+ const SYMPTOM = /\b(werkt niet|faalt|fout(melding)?|error|traag|onduidelijk|breaks?|broken|fails?|failing|crash(es|t|ed)?|kapot|hangs?|timeout|not working|doesn'?t work|mislukt|hangt|breekt|lukt niet|doet het niet|gaat (er )?(mis|fout))\b/i;
133
+ // v0.10: the Dutch counterparts of the English verbs, SEPARABLE ones included. "voeg toe" and "pas aan"
134
+ // never matched as written, because Dutch puts the object between the halves ("voeg een knop toe").
135
+ const BUILD_VERB = /^\W*(bouw|maak|schrijf|fix|voeg (\S+ ){0,8}?toe|verwijder|pas (\S+ ){0,8}?aan|implementeer|refactor|update|build|make|write|add|implement|create|remove|change|refactor|update|generate|convert|rename|migrate|los (\S+ ){0,8}?op|werk (\S+ ){0,8}?bij|haal (\S+ ){0,8}?weg|zet (\S+ ){0,8}?om|herstel|repareer|wijzig|verander|genereer|converteer|hernoem|migreer|creëer)\b/i;
136
+ const OUTCOME_MARK = /\b(het resultaat moet|ik wil eindigen met|end state|eindtoestand|zodat|so that|the result should|i want to end up with|het eindresultaat)\b/i;
137
+ const EXPLORE_MARK = /\b(wat zijn de opties|hoe zou je|welke manieren|what are the options|how would you|what would you|wat zou je|which approaches|welke aanpakken)\b/i;
138
+ const CONTINUE_MARK = /\b(ga verder|ga door|zoals besproken|verder met|continue|as before|as discussed|pick up where|where we left|waar we gebleven (waren|zijn))\b/i;
139
+ const QUESTION_WORD = /^\W*(wat|hoe|waarom|welke|wanneer|kan|kun|zou|is|what|how|why|which|when|can|could|would|should|is|are|does|do)\b/i;
140
+
141
+ /** The share of a message that is pasted material: fenced code, quoted lines, logs and stack traces, dense symbol lines. */
142
+ export function pastedShare(text) {
143
+ if (!text) return 0;
144
+ let pasted = 0;
145
+ const fences = text.match(/```[\s\S]*?```/g) ?? [];
146
+ for (const block of fences) pasted += block.length;
147
+ const rest = text.replace(/```[\s\S]*?```/g, "");
148
+ for (const line of rest.split("\n")) {
149
+ const symbols = (line.match(/[{}();=<>\[\]|\\\/$#@]/g) ?? []).length;
150
+ if (/^\s*>/.test(line) || /^ {4}/.test(line) || /^\s*at .+:\d+/.test(line) || /\b(Traceback|Exception|ERR_|error TS\d|\d{4}-\d{2}-\d{2}T\d{2}:\d{2})/.test(line) || (line.length > 12 && symbols / line.length > 0.12)) pasted += line.length + 1;
151
+ }
152
+ return Math.min(1, pasted / Math.max(1, text.length));
153
+ }
154
+ const sentencesOf = (text) => text.replace(/```[\s\S]*?```/g, " ").split(/(?<=[.!?])\s+|\n+/).map((part) => part.trim()).filter((part) => part.length > 0);
155
+ /** Which sentence carries the ask: the first with a question mark, a build verb or an open-question opener; else the last. */
156
+ const askIndex = (sentences) => { const i = sentences.findIndex((part) => /\?/.test(part) || BUILD_VERB.test(part) || EXPLORE_MARK.test(part) || QUESTION_WORD.test(part)); return i >= 0 ? i : sentences.length - 1; };
157
+
158
+ export function classifyOpening(text) {
159
+ const clean = String(text ?? "").trim();
160
+ if (!clean) return "UNCLASSIFIED";
161
+ if (pastedShare(clean) > 0.6) return "ARTEFACT"; // rule 1
162
+ const sentences = sentencesOf(clean);
163
+ if (sentences.length === 0) return "UNCLASSIFIED";
164
+ if (ROLE_OPENING.test(sentences[0])) return "ROLE"; // rule 2
165
+ const ask = askIndex(sentences);
166
+ if (sentences.slice(0, ask).filter((part) => CONSTRAINT_MARK.test(part)).length >= 2) return "CONSTRAINT"; // rule 3
167
+ const question = sentences[ask];
168
+ // Rule 4 reads the problem STATEMENT too: "the deploy fails … What is going on?" states the
169
+ // symptom in the sentence before the question, and that is the PROBLEM form, not an unplaced one.
170
+ if (SYMPTOM.test(sentences.slice(0, ask + 1).join(" ")) && !BUILD_VERB.test(question)) return "PROBLEM"; // rule 4
171
+ if (OUTCOME_MARK.test(question) && !BUILD_VERB.test(question)) return "OUTCOME"; // rule 5
172
+ if (EXPLORE_MARK.test(question)) return "EXPLORE"; // rule 6
173
+ if (BUILD_VERB.test(question)) return "SOLUTION"; // rule 7
174
+ if (CONTINUE_MARK.test(clean) && !/\?/.test(clean)) return "CONTINUE"; // rule 8
175
+ return "UNCLASSIFIED"; // rule 9: counted, not guessed
176
+ }
177
+ /** instruction | question | context — what kind of act the opening is, orthogonal to its form. */
178
+ export function openingActKind(text, form) {
179
+ if (form === "ROLE" || form === "SOLUTION") return "instruction";
180
+ if (form === "EXPLORE" || /\?/.test(String(text ?? "").split("\n")[0] ?? "")) return "question";
181
+ return "context";
182
+ }
183
+ const CORRECT_MARK = /^\W*(nee|no|nope|niet|not (quite|that|what)|wrong|klopt niet|fout|dat is niet|that'?s not|i meant|ik bedoel(de)?|bedoel|instead of|in plaats van)\b/i;
184
+ const REDIRECT_MARK = /\b(andere aanpak|different approach|other approach|in plaats daarvan|instead,? let'?s|laten we overstappen|plan b|another way|change of plan|laat (dit|dat) (maar )?(liggen|zitten)|let'?s (drop|park) (this|that))\b/i;
185
+ export function classifySecondTurn(text) {
186
+ const clean = String(text ?? "").trim();
187
+ if (!clean) return "UNCLASSIFIED";
188
+ if (REDIRECT_MARK.test(clean)) return "REDIRECT";
189
+ if (CORRECT_MARK.test(clean)) return "CORRECT";
190
+ if (ACCEPT_TURN.test(clean) || (clean.split(/\s+/).length <= 6 && !/\?/.test(clean))) return "ACCEPT";
191
+ if (/\?/.test(clean) || QUESTION_WORD.test(clean)) return "DEEPEN";
192
+ return "UNCLASSIFIED";
193
+ }
194
+ const ACCEPT_TURN = /^\s*(yes|yeah|yep|sure|ok(ay)?|please|do it|go ahead|go|looks good|lgtm|perfect|great|ja|graag|prima|goed|top|ga door|ga verder|doe (het|maar)|oké|akkoord|klopt|precies)\b/i;
195
+
196
+ /**
197
+ * ── v0.10: WHOSE TURN A LINE IS (the transcript's own format, read before any rule) ─────────
198
+ *
199
+ * A Claude Code transcript writes more than the person's typing on the user role: a slash command
200
+ * as `<command-name>/x</command-name>` tags (so a `^/` test never matched and toolInitiator.person
201
+ * was never counted), a local command's output as `<local-command-stdout>`, a background task's
202
+ * completion as `<task-notification>`, a compaction summary (`isCompactSummary`), an interruption
203
+ * marker ("[Request interrupted by user]"), and, since the `origin` field exists, turns whose origin
204
+ * is another agent ("peer") or a task notification. None of those is the person framing anything;
205
+ * each was counted as a human turn and could become a session's OPENING. `personTurn` answers
206
+ * null for them, the command's own arguments for a slash command, and `interrupt` for the marker
207
+ * (the person pressing stop is a redirect, not a message).
208
+ */
209
+ const MACHINE_TURN = /^\s*(<task-notification>|<local-command-(stdout|stderr|caveat)>|Caveat: The messages below were generated|This session is being continued from a previous conversation)/;
210
+ export function personTurn(line) {
211
+ // A message the person typed WHILE the agent ran is not a user line at all: Claude Code queues it
212
+ // and writes it as a `queued_command` attachment (commandMode "prompt") inside the run. It was
213
+ // invisible to every rule, and it is the one turn that is mid-run by construction.
214
+ if (line?.type === "attachment" && line.attachment?.type === "queued_command") {
215
+ const queued = line.attachment;
216
+ if (queued.commandMode !== "prompt" || queued.isMeta || (queued.origin?.kind && queued.origin.kind !== "human")) return null;
217
+ const text = typeof queued.prompt === "string" ? queued.prompt : Array.isArray(queued.prompt) ? queued.prompt.filter((part) => part?.type === "text").map((part) => part.text).join("\n") : "";
218
+ if (!text.trim() || MACHINE_TURN.test(text)) return null;
219
+ return { text, slash: null, interrupt: false, queued: true };
220
+ }
221
+ if (!line || line.type !== "user" || line.isMeta || line.isCompactSummary || line.isVisibleInTranscriptOnly) return null;
222
+ if (line.origin && typeof line.origin === "object" && line.origin.kind && line.origin.kind !== "human") return null;
223
+ if (typeof line.turnOrigin === "string" && line.turnOrigin !== "human") return null;
224
+ const content = line.message?.content;
225
+ let text = null;
226
+ if (typeof content === "string") text = content;
227
+ else if (Array.isArray(content)) { const texts = content.filter((part) => part?.type === "text").map((part) => part.text); if (texts.length > 0) text = texts.join("\n"); }
228
+ if (text === null) return null;
229
+ if (/^\s*\[Request interrupted by user/.test(text)) return { text: "", slash: null, interrupt: true };
230
+ if (MACHINE_TURN.test(text)) return null;
231
+ const command = /<command-name>\s*\/?([^<\s]+)\s*<\/command-name>/.exec(text);
232
+ if (command) return { text: (/<command-args>([\s\S]*?)<\/command-args>/.exec(text)?.[1] ?? "").trim(), slash: command[1], interrupt: false };
233
+ return { text, slash: /^\s*\/[a-z]/i.test(text) ? (/^\s*\/([^\s]+)/.exec(text)?.[1] ?? null) : null, interrupt: false };
234
+ }
235
+
236
+ /**
237
+ * ── v0.10: VALIDATION EXECUTED, not only requested ────────────────────────────────────────
238
+ *
239
+ * A Bash tool call whose command runs a test runner is a test RUN (`testingBehaviour`, the
240
+ * register's own key: tests written or run against the result; the counter counts runs only),
241
+ * and its tool result says how it ended (`testOutcome.PASSED / FAILED / UNKNOWN`). The command and
242
+ * the result are read HERE and never travel: a count per month and a unit per run. Asking for a
243
+ * test stays `verificationAttempts`; whether a run followed the ask is `verificationFollowed`.
244
+ */
245
+ const TEST_COMMAND = /(^|[\s;&|(])((pnpm|npm|yarn|bun)\s+(run\s+)?test(:[\w:-]+)?\b|(npx\s+|pnpm\s+(exec\s+)?|yarn\s+)?(vitest|jest|mocha|ava|playwright\s+test|cypress\s+run)\b|pytest\b|python3?\s+-m\s+(pytest|unittest)\b|go\s+test\b|cargo\s+(test|nextest)\b|deno\s+test\b|node\s+--test\b|mvn\s+(-\S+\s+)*test\b|(\.\/)?gradlew?\s+(\S+\s+)*test\b|dotnet\s+test\b|(bundle\s+exec\s+)?rspec\b|phpunit\b|node\s+(\S*\/)?test[-_][\w.-]*\.(m?js|ts)\b)/;
246
+ export const isTestCommand = (command) => TEST_COMMAND.test(String(command ?? ""));
247
+ export function testOutcomeOf(body, isError) {
248
+ const text = String(body ?? "");
249
+ if (isError === true) return "FAILED";
250
+ if (/\b[1-9]\d* (failed|failing|failures?|errors?)\b|^\s*FAIL\b|\bFAILED\b|\btests? failed\b|\bnot ok \d/m.test(text)) return "FAILED";
251
+ if (/\b\d+ (passed|passing)\b|^\s*PASS\b|\bok\s+\S+\s+[\d.]+s\b|\ball tests pass(ed)?\b|\btest result: ok\b|^ok$|\bPASS:/m.test(text)) return "PASSED";
252
+ return "UNKNOWN";
253
+ }
254
+
255
+ /**
256
+ * ── v0.10: INITIATIVE — what the turn that carried a behaviour came after ──────────────────
257
+ *
258
+ * R12 already withholds framing, depth and pushback from a turn that answers the model's own
259
+ * offer (`modelInducedTurns`, the unit's `induced` flag). What it did not say is WHICH prompt a
260
+ * behaviour followed. Per human turn that fires at least one lexical rule: SPONTANEOUS (no answer
261
+ * directly before it), AFTER_HINT (the answer before it pointed at the cause or the way: "hint",
262
+ * "consider", "the issue is"), AFTER_OFFER (it offered a choice: "shall I", "options:"), AFTER_QUESTION
263
+ * (it ended in a question). Lexical on the model's text, on this machine; a distribution, never a score.
264
+ */
265
+ const HINT_MARK = /\b(hint|tip|consider|have you considered|you might (want to )?(check|look)|look at|note that|the (issue|problem|failure|cause|bug) (is|appears|seems|lies)|it (appears|seems) (that|to)|overweeg|denk aan|kijk (eens )?naar|let op|het probleem (zit|ligt|is)|de oorzaak)\b/i;
266
+ const OFFER_WORDS = /\b(shall i|want me to|would you like|do you want|do you prefer|which do you|options?:|zal ik|wil je (dat ik|de|een)|welke (wil|heb) je)\b/i;
267
+ export function initiativeOf(answerText, directlyAfterAnswer) {
268
+ if (!directlyAfterAnswer) return "SPONTANEOUS";
269
+ const tail = String(answerText ?? "").trimEnd().slice(-400);
270
+ if (HINT_MARK.test(tail)) return "AFTER_HINT";
271
+ if (OFFER_WORDS.test(tail)) return "AFTER_OFFER";
272
+ if (/\?\s*$/.test(tail)) return "AFTER_QUESTION";
273
+ return "SPONTANEOUS";
274
+ }
275
+
276
+ /**
277
+ * ── v0.10: THE TASK SEQUENCE within a session ─────────────────────────────────────────────
278
+ *
279
+ * Five phases, each read from signals the counter already counts, by the turn it FIRST appears
280
+ * in: CRITERIA (criteriaStated, constraintsDefined), ALTERNATIVES (alternativesRequested,
281
+ * tradeOffsNamed), DECISION (decisionAnnouncement), CORRECTION (counterarguments, or a turn after an
282
+ * answer opening with a correction), VALIDATION (verificationAttempts, or a test run). One value per
283
+ * session: NONE, ONE (a single phase), CANONICAL (two or more, in the order above) or OTHER.
284
+ */
285
+ export const SEQUENCE_PHASES = ["CRITERIA", "ALTERNATIVES", "DECISION", "CORRECTION", "VALIDATION"];
286
+ const PHASE_OF = { criteriaStated: "CRITERIA", constraintsDefined: "CRITERIA", alternativesRequested: "ALTERNATIVES", tradeOffsNamed: "ALTERNATIVES", decisionAnnouncement: "DECISION", counterarguments: "CORRECTION", verificationAttempts: "VALIDATION", testingBehaviour: "VALIDATION" };
287
+ export function taskSequenceOf(firstSeen) {
288
+ const present = SEQUENCE_PHASES.filter((phase) => firstSeen.has(phase));
289
+ if (present.length === 0) return "NONE";
290
+ if (present.length === 1) return "ONE";
291
+ const byTime = [...present].sort((a, b) => firstSeen.get(a) - firstSeen.get(b) || SEQUENCE_PHASES.indexOf(a) - SEQUENCE_PHASES.indexOf(b));
292
+ return byTime.every((phase, i) => phase === present[i]) ? "CANONICAL" : "OTHER";
293
+ }
294
+ /** The framing a turn may add to an acceptance: its own criteria, limits, trade-offs, assumptions, edge cases, cuts or order. */
295
+ const OWN_TERMS = new Set(["criteriaStated", "constraintsDefined", "tradeOffsNamed", "assumptionsSurfaced", "edgeCases", "scopeCuts", "prioritisation"]);
296
+
297
+ /** Cohen's κ over labelled pairs `[a, b]` — chance-corrected agreement, never the raw share. */
298
+ export function cohenKappa(pairs) {
299
+ const n = pairs.length;
300
+ if (n < 2) return null; // one item has no marginal — the same edge as core's MIN_ITEMS_FOR_KAPPA
301
+ const categories = [...new Set(pairs.flat())];
302
+ if (categories.length < 2) return null; // one label on both sides: κ undefined, never 1 or 0 for "not computable"
303
+ let agree = 0;
304
+ const marginA = new Map(), marginB = new Map();
305
+ for (const [a, b] of pairs) { if (a === b) agree += 1; marginA.set(a, (marginA.get(a) ?? 0) + 1); marginB.set(b, (marginB.get(b) ?? 0) + 1); }
306
+ const po = agree / n;
307
+ const pe = categories.reduce((sum, c) => sum + ((marginA.get(c) ?? 0) / n) * ((marginB.get(c) ?? 0) / n), 0);
308
+ if (pe === 1) return null;
309
+ return Math.round(((po - pe) / (1 - pe)) * 10_000) / 10_000;
310
+ }
311
+ export const KAPPA_FLOOR = 0.6; // proposed — the measurement review: substantial agreement, per field per language class
312
+ export const STAGE_MAX_BYTES = 3_500_000; // the host takes about 4.5 MB per request; a margin under it
313
+ export const COUNT_CEILING = 100_000; // the door refuses a count above this; a month past it is clamped and SAID, never refused whole
314
+ const clampCount = (signal, count, month) => { if (count <= COUNT_CEILING) return count; console.log(` note: ${month} ${signal} ${count} clamped to ${COUNT_CEILING} — the door's ceiling; the true count stays on this machine`); return COUNT_CEILING; };
315
+ export const UNCLASSIFIED_CEILING = 0.1; // proposed — chapter 8: under it the counter's forms may be trusted as forms
316
+
317
+ /**
318
+ * ── v0.9: THE SKILL MATRIX (chapter 4), read locally ──────────────────────────────────────
319
+ *
320
+ * A skill, an agent definition or a CLAUDE.md already carries the role; an opening that restates
321
+ * it pays tokens and turns for context that is already there. `contextRestated` counts the opening
322
+ * sentences that a loaded skill's own text already carries — lexical overlap, sentence against
323
+ * sentence, on this machine, where the skill file lives. The skill's NAME may travel (the door
324
+ * already stores skill invocations); its text never does. `patternChange` is a skill loaded while
325
+ * the role sentence of the opening shares nothing with it; `recurringPatternCandidate` is a role
326
+ * sentence recurring in three or more sessions of a month without any skill loaded — the pattern
327
+ * is the candidate, never the person.
328
+ */
329
+ const WORD = /[\p{L}\p{N}]{3,}/gu;
330
+ const wordsOf = (sentence) => new Set((sentence.toLowerCase().match(WORD) ?? []));
331
+ const jaccard = (a, b) => { if (a.size === 0 || b.size === 0) return 0; let both = 0; for (const w of a) if (b.has(w)) both += 1; return both / (a.size + b.size - both); };
332
+ export const RESTATED_OVERLAP = 0.5; // proposed — half the words of a sentence already in the skill's own sentence
333
+ export const DRIFT_OVERLAP = 0.2; // proposed — under this a role sentence shares nothing meaningful with the loaded skill
334
+ export const RECURRENCE_FLOOR = 3; // chapter 4.3 — a role text recurring in three or more sessions without a skill
335
+ /** Opening sentences (six words or more) that a skill text already carries — the count, never the sentences. */
336
+ export function restatedSentences(opening, skillText) {
337
+ const skillSets = sentencesOf(String(skillText ?? "")).map(wordsOf).filter((set) => set.size >= 4);
338
+ if (skillSets.length === 0) return 0;
339
+ return sentencesOf(String(opening ?? "")).map(wordsOf).filter((set) => set.size >= 6).filter((set) => skillSets.some((skill) => jaccard(set, skill) >= RESTATED_OVERLAP)).length;
340
+ }
341
+ const skillTextCache = new Map();
342
+ const checkpointDependencies = {};
343
+ /** The text of a skill by name, from this machine only: the user's skills, then the project's. Read once; never sent. */
344
+ function skillTextOf(name, cwd) {
345
+ const key = `${cwd ?? ""}|${name}`;
346
+ if (skillTextCache.has(key)) return skillTextCache.get(key);
347
+ const candidates = [join(homedir(), ".claude", "skills", name, "SKILL.md"), ...(cwd ? [join(cwd, ".claude", "skills", name, "SKILL.md")] : [])];
348
+ let text = "";
349
+ for (const file of candidates) { try { text = readFileSync(file, "utf8"); checkpointDependencies[file] = fingerprint(Buffer.from(text)); break; } catch { /* not here */ } }
350
+ skillTextCache.set(key, text);
351
+ return text;
352
+ }
353
+ const normaliseRole = (sentence) => [...wordsOf(sentence)].sort().join(" ");
354
+
355
+ /**
356
+ * ── v0.9: THE GIT SIDE (chapter 6: testFirstOrder, adrWritten) ─────────────────────────────
357
+ *
358
+ * Two keys nothing could write: whether a test file was committed BEFORE the file it tests, and
359
+ * whether an architecture decision record was added. Both are visible in the person's own
360
+ * repositories on this machine, and nowhere else the counter can reach. So the counter asks git —
361
+ * `git log` with names and statuses only, the person's own commits by the e-mail git itself holds
362
+ * — inside the window, per project directory a transcript named. A path decides a class here the
363
+ * way it does in the connector, and no path travels: a count per month, a unit per commit.
364
+ */
365
+ const TEST_FILE = /(^|\/)(__tests__|tests?|spec)\/|\.(test|spec)\.[a-z]+$|_test\.(go|py|rb)$/i;
366
+ const ADR_FILE = /(^|\/)(adrs?|docs\/adrs?|architecture\/decisions|decisions)\/[^/]+\.md$|\.adr\.md$/i;
367
+ const SOURCE_FILE = /\.(ts|tsx|js|jsx|mjs|py|go|rb|rs|java|kt|php|cs|swift)$/i;
368
+ /** The implementation a test file stands for: `foo.test.ts` → `foo.ts`; `__tests__/foo.ts` → `foo.ts`; `foo_test.go` → `foo.go`. */
369
+ const implementationOf = (testPath) => {
370
+ const base = testPath.split("/").at(-1) ?? "";
371
+ const m = /^(.+?)(?:\.(?:test|spec)|_test)?\.([a-z]+)$/i.exec(base);
372
+ return m ? `${m[1]}.${m[2]}`.toLowerCase() : null;
373
+ };
374
+ export function gitSideCounts(commits) {
375
+ // commits: [{ sha, at (ISO), files: [{ status, path }] }], oldest first. Returns per-commit counts.
376
+ const testFirstSeen = new Map(); // implementation basename → earliest test commit time
377
+ const out = [];
378
+ const sorted = [...commits].sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
379
+ for (const commit of sorted) {
380
+ let testFirstOrder = 0, adrWritten = 0;
381
+ for (const file of commit.files) {
382
+ if (TEST_FILE.test(file.path)) { const impl = implementationOf(file.path); if (impl && !testFirstSeen.has(impl)) testFirstSeen.set(impl, Date.parse(commit.at)); }
383
+ if (file.status === "A" && ADR_FILE.test(file.path)) adrWritten += 1;
384
+ }
385
+ for (const file of commit.files) {
386
+ if (TEST_FILE.test(file.path) || !SOURCE_FILE.test(file.path)) continue;
387
+ const base = (file.path.split("/").at(-1) ?? "").toLowerCase();
388
+ const earlier = testFirstSeen.get(base);
389
+ if (earlier !== undefined && earlier < Date.parse(commit.at)) { testFirstOrder += 1; testFirstSeen.delete(base); }
390
+ }
391
+ out.push({ sha: commit.sha, at: commit.at, testFirstOrder, adrWritten });
392
+ }
393
+ return out;
394
+ }
395
+ function gitCommitsOf(cwd, sinceIso) {
396
+ const git = (...argv) => { try { return execFileSync("git", ["-C", cwd, ...argv], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { return null; } };
397
+ if (git("rev-parse", "--is-inside-work-tree") !== "true") return [];
398
+ const email = git("config", "user.email");
399
+ if (!email) return [];
400
+ const log = git("log", `--since=${sinceIso}`, `--author=${email}`, "--no-merges", "--name-status", "--pretty=format:%x01%H%x09%cI");
401
+ if (!log) return [];
402
+ const commits = [];
403
+ for (const line of log.split("\n")) {
404
+ if (line.startsWith("\u0001")) { const [sha, at] = line.slice(1).split("\t"); commits.push({ sha, at, files: [] }); continue; }
405
+ const m = /^([AMDRT])\d*\t(.+?)(?:\t(.+))?$/.exec(line);
406
+ if (m && commits.length > 0) commits[commits.length - 1].files.push({ status: m[1], path: m[3] ?? m[2] });
407
+ }
408
+ return commits;
409
+ }
410
+
411
+
412
+ const args = process.argv.slice(2);
413
+ const flag = (name) => args.includes(`--${name}`);
414
+ const value = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : undefined; };
415
+ // CLI behaviour (not the rubric — ANALYZER_VERSION versions the counting rules alone):
416
+ // with no flags the counter runs DRY and shows everything; only --send transmits. The door's
417
+ // address and token are found in this machine's own client configuration when not given —
418
+ // the same coupling the person already made — so sending is one word, not a paste.
419
+ const SEND = flag("send") || flag("yes");
420
+ const STAGE = flag("stage");
421
+ const DRY = !SEND && !STAGE;
422
+
423
+ const EXCLUDE = [];
424
+ const LEAVE_OUT = [];
425
+ for (let i = 0; i < args.length; i += 1) if (args[i] === "--exclude" && args[i + 1]) EXCLUDE.push(args[i + 1].toLowerCase());
426
+ /**
427
+ * A claude.ai DATA EXPORT (Settings → Privacy → Export data) read beside the transcripts:
428
+ * `--claude-export <folder or conversations.json>`, repeatable. Claude on the web keeps no
429
+ * store on this machine, so its conversations reach the rubric only this way. One conversation
430
+ * is one session; the person's own messages are the turns; `--exclude` matches a conversation's
431
+ * title as it matches a project directory. The same rubric, the same filter, the same units by
432
+ * digest — the export's text is read here and goes nowhere.
433
+ */
434
+ const EXPORTS = [];
435
+ for (let i = 0; i < args.length; i += 1) if (args[i] === "--claude-export" && args[i + 1]) EXPORTS.push(args[i + 1]);
436
+ try {
437
+ const config = JSON.parse(readFileSync(join(homedir(), ".worktrust-counter.json"), "utf8"));
438
+ for (const entry of Array.isArray(config.exclude) ? config.exclude : []) EXCLUDE.push(String(entry).toLowerCase());
439
+ // GUARDRAILS (2026-09-27): `leaveOut` names WORDS a turn must not carry (a client, a project, a
440
+ // person, a subject); a turn carrying one is a filter miss, like the built-in categories below.
441
+ // The app writes this file from the person's own list; the words never leave this machine.
442
+ for (const entry of Array.isArray(config.leaveOut) ? config.leaveOut : []) { const word = String(entry).trim(); if (word) LEAVE_OUT.push(word); }
443
+ } catch { /* no config file is the common case */ }
444
+ const LEAVE_OUT_RE = LEAVE_OUT.length ? new RegExp(`(^|[^\\p{L}\\p{N}])(${LEAVE_OUT.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})(?![\\p{L}\\p{N}])`, "iu") : null;
445
+ const excluded = (dir) => EXCLUDE.some((needle) => dir.toLowerCase().includes(needle));
446
+ /**
447
+ * v0.8 harness. `--classify` reads JSON lines `{"text": …, "second": …}` from stdin and prints the
448
+ * form, the act kind and the second-turn type per line — the test suite's door, and a way to see
449
+ * what the rules make of one opening. `--agreement <file>` reads JSON lines with the counter's
450
+ * `form` beside a rater's `label` (and `lang`) and reports Cohen's κ per language class and the
451
+ * UNCLASSIFIED share against the floors; exit 1 under them. `--label-openings <file>` makes the
452
+ * counting run write each session's opening (first 240 characters) with the counter's form beside
453
+ * it to that LOCAL file, for the raters — the text goes there and nowhere else. Neither mode
454
+ * reads a transcript or touches the network.
455
+ */
456
+ const LABEL_OUT = (() => { const i = args.indexOf("--label-openings"); return i >= 0 && args[i + 1] ? args[i + 1] : null; })();
457
+ const labelRows = [];
458
+ if (flag("classify")) {
459
+ const input = readFileSync(0, "utf8");
460
+ for (const raw of input.split("\n")) {
461
+ if (!raw.trim()) continue;
462
+ let row; try { row = JSON.parse(raw); } catch { continue; }
463
+ const form = classifyOpening(row.text);
464
+ console.log(JSON.stringify({ form, kind: openingActKind(row.text, form), second: row.second === undefined ? null : classifySecondTurn(row.second), pasted: Math.round(pastedShare(row.text ?? "") * 100) / 100, restated: row.skill === undefined ? null : restatedSentences(row.text, row.skill), gitSide: row.commits === undefined ? null : gitSideCounts(row.commits), testRun: row.command === undefined ? null : { test: isTestCommand(row.command), outcome: row.result === undefined ? null : testOutcomeOf(row.result, row.isError) } }));
465
+ }
466
+ process.exit(0);
467
+ }
468
+ {
469
+ const i = args.indexOf("--agreement");
470
+ if (i >= 0 && args[i + 1]) {
471
+ const rows = readFileSync(args[i + 1], "utf8").split("\n").filter((line) => line.trim()).map((line) => JSON.parse(line)).filter((row) => row.label && row.form);
472
+ const byLang = new Map();
473
+ for (const row of rows) { const lang = row.lang ?? "any"; byLang.set(lang, [...(byLang.get(lang) ?? []), [row.form, row.label]]); }
474
+ const unclassified = rows.length ? rows.filter((row) => row.form === "UNCLASSIFIED").length / rows.length : null;
475
+ const perLanguage = Object.fromEntries([...byLang.entries()].map(([lang, pairs]) => [lang, { n: pairs.length, kappa: cohenKappa(pairs) }]));
476
+ const passes = rows.length >= 100 && unclassified !== null && unclassified <= UNCLASSIFIED_CEILING && Object.values(perLanguage).every((entry) => entry.kappa !== null && entry.kappa >= KAPPA_FLOOR);
477
+ console.log(JSON.stringify({ n: rows.length, unclassified, perLanguage, floors: { kappa: KAPPA_FLOOR, unclassified: UNCLASSIFIED_CEILING, items: 100 }, counterGrade: passes }));
478
+ process.exit(passes ? 0 : 1);
479
+ }
480
+ }
481
+ /**
482
+ * ONLY THE HOUSE'S OWN DOOR (2026-09-27, audit): the coupling used to be "any MCP server whose URL
483
+ * contains /api/mcp", the conventional path of every hosted MCP server, so a person who also held
484
+ * another product's server would have sent their work metadata there under that product's token and
485
+ * fetched a script to run from its origin. A door is WorkTrust's when its host is worktrust.io or a
486
+ * subdomain, or a local one named by WORKTRUST_MCP_URL; nothing else is a coupling.
487
+ */
488
+ const isWorkTrustDoor = (url) => {
489
+ try {
490
+ const parsed = new URL(String(url));
491
+ if (!/\/api\/mcp\/?$/.test(parsed.pathname)) return false;
492
+ const own = process.env.WORKTRUST_MCP_URL ? new URL(process.env.WORKTRUST_MCP_URL).host : null;
493
+ return parsed.protocol === "https:" && (parsed.hostname === "worktrust.io" || parsed.hostname.endsWith(".worktrust.io")) || (own !== null && parsed.host === own) || parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
494
+ } catch { return false; }
495
+ };
496
+ function fromClientConfig() {
497
+ try {
498
+ const config = JSON.parse(readFileSync(join(homedir(), ".claude.json"), "utf8"));
499
+ const found = [];
500
+ const walk = (node) => {
501
+ if (!node || typeof node !== "object") return;
502
+ if (typeof node.url === "string" && isWorkTrustDoor(node.url)) {
503
+ const auth = node.headers?.Authorization ?? node.headers?.authorization ?? "";
504
+ if (auth.startsWith("Bearer ")) found.push({ url: node.url, token: auth.slice(7) });
505
+ }
506
+ for (const child of Object.values(node)) walk(child);
507
+ };
508
+ walk(config);
509
+ return found[0] ?? null;
510
+ } catch { return null; }
511
+ }
512
+ const discovered = fromClientConfig();
513
+ const URL_ = value("url") ?? process.env.WORKTRUST_MCP_URL ?? discovered?.url;
514
+ const TOKEN = value("token") ?? process.env.WORKTRUST_MCP_TOKEN ?? discovered?.token;
515
+ const MONTHS_BACK = Number(value("months") ?? 24);
516
+ if (!Number.isInteger(MONTHS_BACK) || MONTHS_BACK < 1 || MONTHS_BACK > 120) throw Error("months must be an integer from 1 to 120");
517
+
518
+ /**
519
+ * VS CODE COPILOT CHAT SESSIONS (2026-09-08) — the second store on a machine that keeps its own
520
+ * timestamps: workspaceStorage/<workspace>/chatSessions/*.jsonl, one file per chat, a first line
521
+ * with the session's state and delta lines that append requests and response parts. A request
522
+ * carries the person's text, a timestamp in milliseconds, the model as Copilot names it, and the
523
+ * response as parts — markdown strings, tool invocations, edit groups. Replayed here into the
524
+ * transcript's own shape, so ONE rubric reads Claude Code, claude.ai and Copilot alike; the
525
+ * workspace's folder names the context (digested, never sent), and --exclude applies to it.
526
+ * On the machine this was built on, this store held 68 sessions and 12,728 dated requests from
527
+ * April to August that the counter had never seen — the months the mirror was missing.
528
+ */
529
+ function copilotRoots() {
530
+ const home = homedir();
531
+ const candidates = process.platform === "win32"
532
+ ? [join(process.env.APPDATA ?? join(home, "AppData", "Roaming"), "Code", "User", "workspaceStorage")]
533
+ : process.platform === "darwin"
534
+ ? [join(home, "Library", "Application Support", "Code", "User", "workspaceStorage")]
535
+ : [join(home, ".config", "Code", "User", "workspaceStorage")];
536
+ return candidates.filter((root) => { try { return statSync(root).isDirectory(); } catch { return false; } });
537
+ }
538
+ let copilotSessionCount = 0;
539
+ function* copilotSessions() {
540
+ for (const root of copilotRoots()) {
541
+ let workspaces = []; try { workspaces = readdirSync(root); } catch { continue; }
542
+ for (const workspace of workspaces) {
543
+ const dir = join(root, workspace, "chatSessions");
544
+ let files = []; try { files = readdirSync(dir).filter((file) => file.endsWith(".jsonl")); } catch { continue; }
545
+ if (files.length === 0) continue;
546
+ let folder = ""; try { folder = String(JSON.parse(readFileSync(join(root, workspace, "workspace.json"), "utf8")).folder ?? ""); } catch { /* an unnamed workspace still counts, unnamed */ }
547
+ const folderPath = folder.replace(/^file:\/\//, "").replace(/%20/g, " ");
548
+ const basename = folderPath.split("/").filter(Boolean).at(-1) ?? "";
549
+ if (basename && excluded(basename)) { skippedDirs.push(`copilot:${basename}`); continue; }
550
+ if (basename) countedDirs.push(`copilot:${basename}`);
551
+ for (const file of files) {
552
+ let raw; try { raw = readFileSync(join(dir, file), "utf8"); } catch { continue; }
553
+ const requests = [];
554
+ for (const entry of raw.split("\n")) {
555
+ if (!entry) continue;
556
+ let delta; try { delta = JSON.parse(entry); } catch { continue; }
557
+ if (delta.kind === 0) { for (const request of delta.v?.requests ?? []) requests.push(request); continue; }
558
+ const path = Array.isArray(delta.k) ? delta.k : [];
559
+ if (path[0] !== "requests") continue;
560
+ if (path.length === 1 && delta.kind === 2 && Array.isArray(delta.v)) { for (const request of delta.v) requests.push(request); continue; }
561
+ const index = Number(path[1]);
562
+ if (!Number.isInteger(index) || !requests[index]) continue;
563
+ if (path.length === 3 && path[2] === "response" && delta.kind === 2 && Array.isArray(delta.v)) { requests[index].response = [...(requests[index].response ?? []), ...delta.v]; continue; }
564
+ if (path.length === 3 && delta.kind === 1) requests[index][path[2]] = delta.v;
565
+ }
566
+ if (requests.length === 0) continue;
567
+ copilotSessionCount += 1;
568
+ const lines = [];
569
+ for (const request of requests) {
570
+ const at = Number.isFinite(Number(request.timestamp)) ? new Date(Number(request.timestamp)).toISOString() : null;
571
+ const text = typeof request.message?.text === "string" ? request.message.text : "";
572
+ lines.push({ type: "user", timestamp: at, cwd: folderPath || undefined, message: { content: [{ type: "text", text }] } });
573
+ const parts = [];
574
+ const said = [];
575
+ for (const part of request.response ?? []) {
576
+ if (typeof part?.value === "string") { said.push(part.value); continue; }
577
+ if (part?.kind === "toolInvocationSerialized") parts.push({ type: "tool_use", name: String(part.toolId ?? "tool"), input: {} });
578
+ if (part?.kind === "textEditGroup") parts.push({ type: "tool_use", name: "Edit", input: { file_path: String(part.uri?.path ?? part.uri?.fsPath ?? "") } });
579
+ }
580
+ parts.push({ type: "text", text: said.join("\n") });
581
+ lines.push({ type: "assistant", timestamp: at, message: { model: typeof request.modelId === "string" ? request.modelId : undefined, content: parts } });
582
+ }
583
+ yield { key: `copilot:${workspace}:${file}`, projectDir: basename ? `copilot-${basename}` : `copilot-${workspace}`, basename: basename || null, lines };
584
+ }
585
+ }
586
+ }
587
+ }
588
+
589
+ /** Claude Code transcripts: *.jsonl under the Claude projects directory. */
590
+ function* transcriptFiles() {
591
+ const root = value("transcript-root") ?? join(homedir(), ".claude", "projects");
592
+ let dirs = [];
593
+ try { dirs = readdirSync(root); } catch (error) { if (error.code === "ENOENT" && !value("transcript-root")) return; throw error; }
594
+ for (const dir of dirs) {
595
+ if (excluded(dir)) { skippedDirs.push(dir); continue; }
596
+ countedDirs.push(dir);
597
+ let files = [];
598
+ try { files = readdirSync(join(root, dir)); } catch (error) { if (error.code === "ENOTDIR") continue; throw error; }
599
+ for (const file of files) if (file.endsWith(".jsonl")) yield join(root, dir, file);
600
+ }
601
+ }
602
+ const skippedDirs = [];
603
+ const countedDirs = [];
604
+ let exportConversations = 0;
605
+ const skippedConversations = [];
606
+ function* claudeExportSessions() {
607
+ for (const given of EXPORTS) {
608
+ let path = given.replace(/^~(?=\/|$)/, homedir());
609
+ try { if (statSync(path).isDirectory()) path = join(path, "conversations.json"); } catch { throw Error(`claude export not found: ${given}`); }
610
+ let conversations; try { conversations = JSON.parse(readFileSync(path, "utf8")); } catch { throw Error(`claude export unreadable: ${path}`); }
611
+ if (!Array.isArray(conversations)) throw Error("claude export must contain an array of conversations");
612
+ for (const conversation of conversations) {
613
+ const title = String(conversation?.name ?? "");
614
+ if (excluded(title)) { skippedConversations.push(title.slice(0, 40)); continue; }
615
+ const messages = Array.isArray(conversation?.chat_messages) ? conversation.chat_messages : [];
616
+ if (messages.length === 0) continue;
617
+ exportConversations += 1;
618
+ // The export's messages in the transcript's own shape, so one rubric reads both.
619
+ const lines = messages.map((m) => {
620
+ const text = typeof m?.text === "string" && m.text.length > 0 ? m.text : (Array.isArray(m?.content) ? m.content.filter((c) => c?.type === "text" && typeof c.text === "string").map((c) => c.text).join("\n") : "");
621
+ const at = m?.created_at ?? conversation?.created_at ?? null;
622
+ return { type: m?.sender === "human" ? "user" : "assistant", timestamp: at, message: { content: [{ type: "text", text }] } };
623
+ });
624
+ yield { key: `claude.ai:${conversation?.uuid ?? title}`, projectDir: "claude-ai", basename: "claude.ai", lines };
625
+ }
626
+ }
627
+ }
628
+ // ── codex rollout reader (the same text in count-behaviour.mjs and log-session.mjs; test-codex-rollout holds them equal) ──
629
+ /**
630
+ * A CODEX ROLLOUT, READ AS A TRANSCRIPT (2026-09-27). Codex keeps one JSONL per thread under
631
+ * ~/.codex/sessions/YYYY/MM/DD/rollout-<stamp>-<uuid>.jsonl: a session_meta line, then per turn a
632
+ * turn_context (model, effort, cwd), response_items (messages, tool calls, their outputs) and a
633
+ * token_usage_record per model response. On the machine this was built on five of them held 679
634
+ * million tokens that nothing read. This turns the records into the lines Claude Code writes, so
635
+ * ONE rubric and ONE clock read both:
636
+ * · a user or assistant message → a user or assistant line with a text part; the developer line
637
+ * and the harness's own user-role messages (<environment_context>, <recommended_plugins> and
638
+ * the like: a tag opening the text) are the harness, not the person, and are dropped;
639
+ * · a tool call → an assistant line with a tool_use part: `exec` becomes Bash with the command
640
+ * as input, `apply_patch` an Edit with the first path it names, `spawn_agent` an Agent; its
641
+ * output → a user line with a tool_result, is_error read from Codex's own "exit code N";
642
+ * · a token_usage_record → an assistant line carrying the usage in Claude's keys, ONCE per
643
+ * response_id (Codex writes a response more than once): input minus cached is new input,
644
+ * cached is a cache read, cache_write a cache write, output already holds the reasoning tokens.
645
+ * The running total in event_msg/token_count is never read: it is a counter, not a record.
646
+ * Text is read here for the rubric and the layer and travels nowhere, as with every transcript.
647
+ */
648
+ const CODEX_ROLLOUT = /(^|\/)rollout-\d{4}-\d{2}-\d{2}T[\d-]+-[0-9a-f-]{36}\.jsonl$/;
649
+ const CODEX_HARNESS_TURN = /^\s*<[a-z][a-z_]*[\s>]/i;
650
+ const codexToolName = (name) => (name === "exec" || name === "shell" || name === "container.exec" || name === "local_shell" ? "Bash" : name === "apply_patch" ? "Edit" : name === "spawn_agent" ? "Agent" : String(name ?? "tool"));
651
+ const codexToolInput = (name, raw) => {
652
+ const mapped = codexToolName(name);
653
+ if (mapped === "Bash") {
654
+ if (typeof raw === "string") { try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === "object") return { command: Array.isArray(parsed.command) ? parsed.command.join(" ") : String(parsed.command ?? parsed.cmd ?? raw) }; } catch { /* the string is the command */ } return { command: raw }; }
655
+ return { command: Array.isArray(raw?.command) ? raw.command.join(" ") : String(raw?.command ?? raw?.cmd ?? "") };
656
+ }
657
+ if (mapped === "Edit") { const path = /\*\*\* (?:Update|Add|Delete) File: ([^\n]+)/.exec(typeof raw === "string" ? raw : JSON.stringify(raw ?? "")); return { file_path: path ? path[1].trim() : "" }; }
658
+ return {};
659
+ };
660
+ const codexOutputText = (output) => (typeof output === "string" ? output : Array.isArray(output) ? output.map((part) => (typeof part === "string" ? part : part?.text ?? "")).join("\n") : "");
661
+ /** The rollout's raw JSON lines → transcript-shaped line objects, in file order. */
662
+ function* codexLines(records) {
663
+ let cwd = null, model = null;
664
+ const seenResponses = new Set();
665
+ for (const raw of records) {
666
+ if (!raw || !String(raw).trim()) continue;
667
+ let record; try { record = JSON.parse(raw); } catch { continue; }
668
+ const timestamp = typeof record?.timestamp === "string" ? record.timestamp : null;
669
+ const payload = record?.payload && typeof record.payload === "object" ? record.payload : {};
670
+ if (record.type === "session_meta") { if (typeof payload.cwd === "string") cwd = payload.cwd; continue; }
671
+ if (record.type === "turn_context") { if (typeof payload.cwd === "string") cwd = payload.cwd; if (typeof payload.model === "string") model = payload.model; continue; }
672
+ if (record.type === "token_usage_record") {
673
+ const id = String(payload.response_id ?? "");
674
+ const usage = payload.usage;
675
+ if (!usage || typeof usage !== "object" || (id && seenResponses.has(id))) continue;
676
+ if (id) seenResponses.add(id);
677
+ const n = (key) => (typeof usage[key] === "number" && Number.isFinite(usage[key]) ? usage[key] : 0);
678
+ yield { type: "assistant", timestamp, cwd, uuid: id ? `codex-usage-${id}` : undefined, message: { ...(model ? { model } : {}), content: [], usage: { input_tokens: Math.max(0, n("input_tokens") - n("cached_input_tokens")), output_tokens: n("output_tokens"), cache_read_input_tokens: n("cached_input_tokens"), cache_creation_input_tokens: n("cache_write_input_tokens") } } };
679
+ continue;
680
+ }
681
+ if (record.type !== "response_item") continue;
682
+ const kind = payload.type;
683
+ if (kind === "message") {
684
+ if (payload.role !== "user" && payload.role !== "assistant") continue;
685
+ const text = Array.isArray(payload.content) ? payload.content.filter((part) => typeof part?.text === "string").map((part) => part.text).join("\n") : typeof payload.content === "string" ? payload.content : "";
686
+ if (payload.role === "user" && CODEX_HARNESS_TURN.test(text)) continue;
687
+ yield { type: payload.role, timestamp, cwd, uuid: payload.id ? `codex-${payload.id}` : undefined, message: { ...(payload.role === "assistant" && model ? { model } : {}), content: [{ type: "text", text }] } };
688
+ continue;
689
+ }
690
+ if (kind === "custom_tool_call" || kind === "function_call") {
691
+ const input = kind === "function_call" ? (() => { try { return JSON.parse(payload.arguments ?? "{}"); } catch { return {}; } })() : payload.input;
692
+ yield { type: "assistant", timestamp, cwd, uuid: payload.id ? `codex-${payload.id}` : undefined, message: { ...(model ? { model } : {}), content: [{ type: "tool_use", ...(payload.call_id ? { id: String(payload.call_id) } : {}), name: codexToolName(payload.name), input: codexToolInput(payload.name, input) }] } };
693
+ continue;
694
+ }
695
+ if (kind === "custom_tool_call_output" || kind === "function_call_output") {
696
+ const text = codexOutputText(payload.output);
697
+ const exit = /(?:failed with|exited with|exit code)[:\s]+(-?\d+)/i.exec(text.slice(-400));
698
+ yield { type: "user", timestamp, cwd, uuid: payload.id ? `codex-${payload.id}` : undefined, message: { content: [{ type: "tool_result", ...(payload.call_id ? { tool_use_id: String(payload.call_id) } : {}), content: text, is_error: exit ? exit[1] !== "0" : false }] } };
699
+ }
700
+ }
701
+ }
702
+ /** Every rollout under a Codex sessions root, in a stable order. */
703
+ function* codexRolloutFiles(root) {
704
+ let entries = []; try { entries = readdirSync(root, { withFileTypes: true }); } catch { return; }
705
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
706
+ const full = join(root, entry.name);
707
+ if (entry.isDirectory()) yield* codexRolloutFiles(full);
708
+ else if (CODEX_ROLLOUT.test(entry.name)) yield full;
709
+ }
710
+ }
711
+ /** The rollout's working directory, from its first line alone: the file is not read for it. */
712
+ function codexCwd(file) {
713
+ let fd; try { fd = openSync(file, "r"); } catch { return null; }
714
+ try {
715
+ const buffer = Buffer.allocUnsafe(64 * 1024);
716
+ const read = readSync(fd, buffer, 0, buffer.length, 0);
717
+ const first = buffer.toString("utf8", 0, read).split("\n")[0] ?? "";
718
+ const meta = JSON.parse(first);
719
+ return meta?.type === "session_meta" && typeof meta.payload?.cwd === "string" ? meta.payload.cwd : null;
720
+ } catch { return null; } finally { closeSync(fd); }
721
+ }
722
+ // ── end codex rollout reader ──
723
+
724
+ /**
725
+ * Codex rollouts, as sessions. The real ~/.codex/sessions is read when nothing was overridden;
726
+ * a run that points the counter at another transcript root (a test) reads Codex only when it names
727
+ * a root too, so a synthetic case is never joined by the machine's own threads. `--no-codex` leaves
728
+ * them out; `--exclude` matches the thread's working directory, read from its first line before
729
+ * anything else is opened.
730
+ */
731
+ let codexRolloutCount = 0;
732
+ function* codexSessions() {
733
+ if (flag("no-codex")) return;
734
+ const given = value("codex-root");
735
+ if (!given && value("transcript-root")) return;
736
+ const root = (given ?? join(homedir(), ".codex", "sessions")).replace(/^~(?=\/|$)/, homedir());
737
+ for (const file of codexRolloutFiles(root)) {
738
+ const cwd = codexCwd(file);
739
+ const basename = cwd ? String(cwd).split("/").filter(Boolean).at(-1) ?? null : null;
740
+ if (basename && excluded(basename)) { skippedDirs.push(`codex:${basename}`); continue; }
741
+ const projectDir = basename ? `codex-${basename}` : "codex";
742
+ if (!countedDirs.includes(projectDir)) countedDirs.push(projectDir);
743
+ codexRolloutCount += 1;
744
+ yield { key: file, projectDir, basename, path: file, codex: true };
745
+ }
746
+ }
747
+ /** Every session the rubric reads: a transcript file, or one conversation from a claude.ai export. */
748
+ /**
749
+ * v0.10: a transcript is READ AS A STREAM of lines. Reading a whole file into one string failed
750
+ * outright on a transcript past V8's string ceiling (about 512 MB; a long agent session with
751
+ * pasted images reaches 1.7 GB), so the counter could not run at all on such a machine. Each line
752
+ * is decoded alone; the bytes feed the file's digest as they pass, so the checkpoint still knows
753
+ * an edited file from an unchanged one without a second copy in memory.
754
+ */
755
+ const CHUNK_BYTES = 16 * 1024 * 1024;
756
+ function* fileLines(path, hash) {
757
+ const fd = openSync(path, "r");
758
+ let rest = Buffer.alloc(0);
759
+ try {
760
+ for (;;) {
761
+ const buffer = Buffer.allocUnsafe(CHUNK_BYTES);
762
+ const read = readSync(fd, buffer, 0, CHUNK_BYTES, null);
763
+ if (read === 0) break;
764
+ hash?.update(buffer.subarray(0, read));
765
+ const chunk = rest.length ? Buffer.concat([rest, buffer.subarray(0, read)]) : buffer.subarray(0, read);
766
+ let start = 0;
767
+ for (let end = chunk.indexOf(10); end !== -1; end = chunk.indexOf(10, start)) { yield decodeLine(chunk, start, end); start = end + 1; }
768
+ rest = Buffer.from(chunk.subarray(start));
769
+ }
770
+ if (rest.length) yield decodeLine(rest, 0, rest.length);
771
+ } finally { closeSync(fd); }
772
+ }
773
+ /** One line as text; a single line past the string ceiling is skipped (counted as unreadable), never fatal. */
774
+ let unreadableLines = 0;
775
+ const decodeLine = (buffer, start, end) => { try { return buffer.toString("utf8", start, end); } catch { unreadableLines += 1; return ""; } };
776
+ /** The digest of a transcript file, streamed: its path and every byte. */
777
+ function fileDigest(path) { const hash = createHash("sha256").update(`file:${path}\n`); for (const _ of fileLines(path, hash)) { /* the bytes are the input */ } return hash.digest("hex"); }
778
+ const sessionDigest = (session) => session.path ? fileDigest(session.path) : fingerprint(session);
779
+ function* sessions() {
780
+ for (const file of transcriptFiles()) {
781
+ yield { key: file, projectDir: file.split("/").slice(-2, -1)[0] ?? "", basename: null, path: file };
782
+ }
783
+ // A rollout is a transcript on this machine, so --only-transcripts keeps it.
784
+ yield* codexSessions();
785
+ if (!flag("only-transcripts")) { yield* claudeExportSessions(); yield* copilotSessions(); }
786
+ }
787
+
788
+ /**
789
+ * v0.10: ONE LINE, COUNTED ONCE. Claude Code writes a resumed or forked session into a new file
790
+ * that carries the earlier conversation's lines again, under the same `uuid`, and a copied
791
+ * transcript carries every line twice. Keyed by file path, both counted the same turns twice (on
792
+ * the machine this was built on, about forty thousand person-role lines and fifty-eight thousand
793
+ * model lines stood in more than one file). A line's identity is its `uuid` where the transcript
794
+ * gives one, and otherwise a digest of its position, role, timestamp and content (the position keeps
795
+ * two identical short replies inside one conversation apart when a store dates them alike): two
796
+ * sessions that merely say similar things at different moments stay two. The first file in reading order keeps
797
+ * the line; a later file skips it, and a session whose first turns were all copies is a
798
+ * CONTINUATION, whose first new turn is not an opening.
799
+ */
800
+ const lineIdentity = (line, position) => line.uuid ? `u:${line.uuid}` : createHash("sha256").update(`${position}|${line.type}|${line.timestamp ?? ""}|${JSON.stringify(line.message ?? line.attachment ?? null)}`).digest("hex").slice(0, 24);
801
+
802
+ /**
803
+ * R12 — model induction. A question or acceptance that directly follows the model's own offer
804
+ * ("Shall I…?", "Options: …", "Want the trade-offs?") is the model steering the person, not the
805
+ * person framing, probing or pushing back. Such a turn is counted ONCE as `modelInducedTurns`
806
+ * and excluded from the framing, depth and pushback signals below (AUDIT F-01).
807
+ */
808
+ const OFFER = /\b(shall i|want me to|would you like|do you want|do you prefer|which do you|options?:|zal ik|wil je (dat ik|de|een)|welke (wil|heb) je)\b|\?\s*$/i;
809
+ const ACCEPT = /^\s*(yes|yeah|sure|ok(ay)?|please|do it|go ahead|ja|graag|prima|ga door|doe (het|maar)|oké)\b/i;
810
+ const R12_SKIP = new Set([
811
+ "constraintsDefined", "criteriaStated", "requirementsDerived", "roleFraming", // framing
812
+ "followUpDepth", "deepFollowUpChains", "gapQuestions", "counterfactualQuestions", "alternativesRequested", "edgeCases", // depth
813
+ "counterarguments", "evidenceDemands", "tradeOffsNamed", "assumptionsSurfaced", "hypothesesRaised", // pushback
814
+ ]);
815
+ /**
816
+ * 7.3 — the filter before the analysis. A turn that carries special-category or family content
817
+ * never enters a count: it is counted once as `filterMiss` (a number, never a category of what
818
+ * was said) and every other signal skips it, so the filter can be improved without anyone
819
+ * reading what it missed (AUDIT F-03). Deliberately narrow; the person can widen it by --exclude.
820
+ */
821
+ const SPECIAL_CATEGORY = /\b(migraine|ziek(te)?|sick(ness)?|illness|doctor|dokter|huisarts|therap(y|ie|ist)|pregnan(t|cy)|zwanger|burn-?out|opgebrand|depress(ie|ion|ed)|church|kerk|mosque|moskee|synagogue|synagoge|eid|ramadan|pesach|divorce|scheiding|funeral|begrafenis|my (wife|husband|partner|kids?|son|daughter|mother|father)|mijn (vrouw|man|partner|kind(eren)?|zoon|dochter|moeder|vader)|election|verkiezing(en)?|vakbond|union membership|porn(ography)?|erotic|nsfw|onlyfans)\b/i;
822
+
823
+ const floor = new Date(); floor.setUTCMonth(floor.getUTCMonth() - MONTHS_BACK, 1);
824
+ const thisMonth = new Date().toISOString().slice(0, 7);
825
+ const byMonth = new Map(); // month → Map(signal → count)
826
+ /**
827
+ * F-02 — every count carries a reference to the UNIT it came from: a 16-hex hash of the
828
+ * transcript file and line (or of the file alone for session-level counts). An id, never a
829
+ * span of text; enough for a later claim to cite evidence by ID (R1) and for the same turn to
830
+ * be counted once across rubrics and connectors (R6). Nothing about the content survives the
831
+ * hash, and the file path itself never travels — only its digest.
832
+ */
833
+ const unitOf = (...parts) => createHash("sha256").update(parts.join(":")).digest("hex").slice(0, 16);
834
+ let currentUnit = null; // the unit the next bump belongs to (a turn, or a session)
835
+ let currentInduced = false; // R12 flag carried on the unit reference
836
+ let currentDay = null; // the day the unit came from (YYYY-MM-DD) — a date, never a time of day
837
+ const unitsByMonth = new Map(); // month → Map("unit|signal" → { unit, signal, induced, day, context })
838
+ /**
839
+ * v0.7 — the CONTEXT: the same observation tallied a second time per project, so the mirror can
840
+ * draw behaviour × project (where a behaviour shows up, and where it does not). The context is
841
+ * a 16-hex digest of the project directory — the directory's name never travels; the person
842
+ * names the context in the app, and this run prints which digest is which so they can.
843
+ */
844
+ const contextOf = (projectDir) => createHash("sha256").update(`context:${projectDir}`).digest("hex").slice(0, 16);
845
+ let currentContext = null; // the project the next bump belongs to
846
+ const contextNames = new Map(); // digest → directory key, printed locally, NEVER sent
847
+ const contextBasenames = new Map(); // digest → the transcript's own cwd basename, the project's real name — local only
848
+ const byMonthContext = new Map(); // month → Map("signal|context" → count)
849
+ /**
850
+ * THE WEEK, beside the month (owner, 2026-09-27: add only what was not added before, per week).
851
+ * The door still stores months; the week is how THIS machine knows which of its months moved
852
+ * since the last delivery, and what to say moved. ISO weeks, cut at the month boundary so a
853
+ * week never straddles two stored months: "2026-09|2026-W36". A count without a day (a
854
+ * month-level aggregate) lands in the month's "U" bucket.
855
+ */
856
+ export const isoWeek = (day) => {
857
+ const date = new Date(`${day}T00:00:00Z`);
858
+ const thursday = new Date(date); thursday.setUTCDate(date.getUTCDate() + 3 - ((date.getUTCDay() + 6) % 7));
859
+ const firstThursday = new Date(Date.UTC(thursday.getUTCFullYear(), 0, 4));
860
+ const week = 1 + Math.round(((thursday - firstThursday) / 86400000 - 3 + ((firstThursday.getUTCDay() + 6) % 7)) / 7);
861
+ return `${thursday.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
862
+ };
863
+ const byWeek = new Map(); // "month|week" → Map(signal → count)
864
+ const bump = (month, signal, n = 1) => {
865
+ {
866
+ const key = `${month}|${currentDay && currentDay.startsWith(month) ? isoWeek(currentDay) : "U"}`;
867
+ const week = byWeek.get(key) ?? new Map();
868
+ week.set(signal, (week.get(signal) ?? 0) + n);
869
+ byWeek.set(key, week);
870
+ }
871
+ if (currentUnit) {
872
+ const bag = unitsByMonth.get(month) ?? new Map();
873
+ bag.set(`${currentUnit}|${signal}`, { unit: currentUnit, signal, induced: currentInduced, day: currentDay, context: currentContext });
874
+ unitsByMonth.set(month, bag);
875
+ }
876
+ const bucket = byMonth.get(month) ?? new Map();
877
+ bucket.set(signal, (bucket.get(signal) ?? 0) + n);
878
+ byMonth.set(month, bucket);
879
+ if (currentContext) {
880
+ const cells = byMonthContext.get(month) ?? new Map();
881
+ cells.set(`${signal}|${currentContext}`, (cells.get(`${signal}|${currentContext}`) ?? 0) + n);
882
+ byMonthContext.set(month, cells);
883
+ }
884
+ };
885
+ const inWindow = (month) => /^\d{4}-\d{2}$/.test(month) && month <= thisMonth && new Date(`${month}-01`) >= floor;
886
+ const monthToolNames = new Map(); // month → Set(tool)
887
+ const openingHashes = new Set(); // normalised session openings, for template reuse
888
+ const sessionSpans = []; // { start, end, month } per counted session, for parallelism
889
+ const modelFirstSeen = new Map(); // model id → first month
890
+ const toolFirstSeen = new Set(); // v0.8: tool, skill and MCP-server names seen anywhere in the record, for firstUseEvent
891
+ const monthMcpServers = new Map(); // v0.9: month → Set(mcp server) for mcpServersDistinct
892
+ const rolePatterns = new Map(); // v0.9: month → Map(normalised role sentence → Set(session unit)), sessions without a skill
893
+ const contextCwds = new Map(); // v0.9: context digest → the real working directory, for the git side; local only
894
+ const projectFirstSeen = new Map(); // project dir → first month with activity
895
+ const sessionsByMonth = new Map(); // month → sessions that started in it: the confidence layer's independence count, sent as `stretches`
896
+ let messages = 0, files = 0, duplicateLines = 0;
897
+ const seenLines = new Set(); // v0.10: line identities already counted (a uuid or a 24-hex digest; never text)
898
+ // Validate the processed prefix before reuse. Edited/truncated/deleted sessions restart the
899
+ // scan; complete session boundaries preserve signals that depend on earlier turns.
900
+ const manifest = [];
901
+ for(const session of sessions())manifest.push(sessionDigest(session));
902
+ exportConversations = 0; copilotSessionCount = 0; codexRolloutCount = 0; skippedDirs.length = 0; countedDirs.length = 0; skippedConversations.length = 0;
903
+ const checkpoint = flag("no-checkpoint") || LABEL_OUT ? null : checkpointStore(
904
+ value("checkpoint") ?? join(homedir(), ".worktrust", "counter-checkpoint.json"),
905
+ fingerprint({code:readFileSync(new URL(import.meta.url)),version:ANALYZER_VERSION,months:MONTHS_BACK,thisMonth,exclude:EXCLUDE,leaveOut:LEAVE_OUT,exports:EXPORTS}),manifest);
906
+ const checkpointMaps = {byMonth,byWeek,unitsByMonth,contextNames,contextBasenames,byMonthContext,monthToolNames,modelFirstSeen,monthMcpServers,rolePatterns,contextCwds,projectFirstSeen,sessionsByMonth};
907
+ const checkpointSets = {openingHashes,toolFirstSeen,seenLines};
908
+ if (checkpoint?.saved) {
909
+ for(const [key,map] of Object.entries(checkpointMaps)) for(const [k,v] of checkpoint.saved.state.maps[key] ?? []) map.set(k,v);
910
+ for(const [key,set] of Object.entries(checkpointSets)) for(const v of checkpoint.saved.state.sets[key] ?? []) set.add(v);
911
+ sessionSpans.push(...checkpoint.saved.state.sessionSpans);
912
+ messages=checkpoint.saved.state.messages;files=checkpoint.saved.state.files;currentContext=checkpoint.saved.state.currentContext;duplicateLines=checkpoint.saved.state.duplicateLines??0;
913
+ Object.assign(checkpointDependencies,checkpoint.saved.dependencies);
914
+ }
915
+ const resumeAt=checkpoint?.saved?.next??0;
916
+ let sessionIndex=0, processed=0;
917
+ const maxSessions=Number(value("batch-sessions")??Number.POSITIVE_INFINITY);
918
+ if (value("batch-sessions") !== undefined && (!Number.isSafeInteger(maxSessions) || maxSessions < 1)) throw Error("batch-sessions must be a positive integer");
919
+ for (const session of sessions()) {
920
+ // A file's digest is taken while its lines stream past and compared when the session ends.
921
+ if(!session.path && fingerprint(session)!==manifest[sessionIndex]) throw Error("Source changed during counting; rerun to resume safely");
922
+ if(sessionIndex++<resumeAt)continue;
923
+ const streamHash = session.path ? createHash("sha256").update(`file:${session.path}\n`) : null;
924
+ const file = session.key;
925
+ files += 1;
926
+ // One file is one session: order preserved, so the STRUCTURAL half reads the shape.
927
+ let sessionMonth = null;
928
+ let humanTurns = 0;
929
+ let toolActions = 0;
930
+ let runLength = 0; // consecutive tool actions since the last human turn
931
+ let lastWasAssistantAnswer = false;
932
+ let opened = false;
933
+ let chain = 0; // consecutive follow-up questions on answers
934
+ let assistantAskedQuestion = false;
935
+ let assistantOffered = false; // R12: the model's last answer offered or asked
936
+ const askedBefore = new Set(); // normalised questions already answered in this session (reassurance loops)
937
+ let firstTs = null;
938
+ let lastTs = null;
939
+ let edited = false; // has this session touched a file yet
940
+ let researchTurnsBeforeEdit = 0;
941
+ // v0.8 session state: the opening's second turn, what context was loaded, the failure→recovery chain, verification before acceptance.
942
+ let awaitingSecond = false, sawAnswerAfterOpening = false, skillSeen = false, agentSeen = false, pendingVerify = false, openFailure = null;
943
+ const recoveredHashes = new Set();
944
+ // v0.9: the skill matrix and turns to the first acceptance.
945
+ const skillsLoaded = new Set();
946
+ let openingText = null, roleSentence = null, turnsAfterOpening = 0, acceptedAtTurn = null;
947
+ // v0.10: whether the agent's run had ENDED with an answer before the person spoke (a turn after a
948
+ // finished run is not a redirect), an interruption marker, the copies skipped before the first
949
+ // new turn (a continuation), the phases' first turns, the model's last answer, the test runs.
950
+ let runFinished = true, interrupted = false, copiedBeforeOpening = false, lastAnswerText = "", turnIndex = 0, verifyOpen = null;
951
+ const phaseFirst = new Map();
952
+ const pendingTests = new Map(); // tool_use id → the run's unit, day and month
953
+ const testQueue = []; // runs without an id (a replayed store), matched to the next result in order
954
+ const projectDir = session.projectDir;
955
+ currentContext = projectDir ? contextOf(projectDir) : null;
956
+ if (projectDir && !contextNames.has(currentContext)) contextNames.set(currentContext, projectDir);
957
+ if (session.basename && currentContext && !contextBasenames.has(currentContext)) contextBasenames.set(currentContext, session.basename);
958
+ const sessionUnit = unitOf(file);
959
+ let lineNo = 0;
960
+ // Streamed either way; a Codex rollout's stream is shaped into transcript lines as it passes.
961
+ const rawLines = session.path ? fileLines(session.path, streamHash) : session.lines;
962
+ for (const entry of session.codex ? codexLines(rawLines) : rawLines) {
963
+ lineNo += 1;
964
+ if (!entry) continue;
965
+ currentUnit = unitOf(file, lineNo);
966
+ currentInduced = false;
967
+ let line;
968
+ if (typeof entry === "string") { try { line = JSON.parse(entry); } catch { continue; } } else line = entry;
969
+ if (!line || typeof line !== "object") continue;
970
+ if (line.type === "user" || line.type === "assistant" || line.type === "attachment") {
971
+ const identity = lineIdentity(line, lineNo);
972
+ if (seenLines.has(identity)) { duplicateLines += 1; if (!opened) copiedBeforeOpening = true; continue; }
973
+ seenLines.add(identity);
974
+ }
975
+ // The transcript names its own working directory; its last segment is the project's real
976
+ // name ("davosnl.com", "dlg-platform") — the directory key above encodes "/" and "." alike
977
+ // as "-", so it cannot say where a name starts. Kept locally, digested for the slug.
978
+ if (currentContext && line.cwd && !contextBasenames.has(currentContext)) { const base = String(line.cwd).split("/").filter(Boolean).at(-1); if (base) contextBasenames.set(currentContext, base); }
979
+ if (currentContext && line.cwd && !contextCwds.has(currentContext)) contextCwds.set(currentContext, String(line.cwd));
980
+ currentDay = line.timestamp ? String(line.timestamp).slice(0, 10) : null;
981
+ const month = line.timestamp ? String(line.timestamp).slice(0, 7) : null;
982
+ // ── structural half ──
983
+ if (line.type === "assistant" && month && inWindow(month)) {
984
+ const parts = Array.isArray(line.message?.content) ? line.message.content : [];
985
+ const model = line.message?.model;
986
+ if (model && typeof model === "string" && !model.startsWith("<")) {
987
+ if (!modelFirstSeen.has(model)) { modelFirstSeen.set(model, month); bump(month, "newModelAdoption"); }
988
+ }
989
+ let answered = false;
990
+ let askText = "";
991
+ for (const part of parts) {
992
+ if (part?.type === "tool_use") {
993
+ toolActions += 1; runLength += 1; runFinished = false;
994
+ // v0.10: a test runner's command is read here, never sent; the run is counted, its result read below.
995
+ if (String(part.name ?? "") === "Bash" && isTestCommand(part.input?.command)) {
996
+ bump(month, "testingBehaviour");
997
+ const run = { unit: currentUnit, day: currentDay, month };
998
+ if (part.id) pendingTests.set(String(part.id), run); else testQueue.push(run);
999
+ if (!phaseFirst.has("VALIDATION")) phaseFirst.set("VALIDATION", turnIndex);
1000
+ if (verifyOpen) { const lineUnit = currentUnit; currentUnit = verifyOpen.unit; currentDay = verifyOpen.day; bump(verifyOpen.month, "verificationFollowed.RUN"); currentUnit = lineUnit; currentDay = run.day; verifyOpen = null; }
1001
+ }
1002
+ if (["Edit", "Write", "MultiEdit", "NotebookEdit"].includes(String(part.name ?? ""))) edited = true;
1003
+ if (runLength === 10) bump(month, "longAgentRuns"); // once per stretch, at the threshold
1004
+ const tool = String(part.name ?? "");
1005
+ const set = monthToolNames.get(month) ?? new Set();
1006
+ set.add(tool); monthToolNames.set(month, set);
1007
+ if (tool.startsWith("mcp__")) bump(month, "mcpToolUse");
1008
+ if (tool === "Agent" || tool === "Task") bump(month, "subagentUse");
1009
+ if (tool === "Skill") bump(month, "skillInvocation");
1010
+ // v0.8: the context loaded before the second turn, who initiated the tool, and first uses.
1011
+ if (tool === "Skill") { skillSeen = true; const skillName = String(part.input?.skill ?? part.input?.name ?? "").trim(); if (skillName) skillsLoaded.add(skillName); }
1012
+ if (tool.startsWith("mcp__")) { const set = monthMcpServers.get(month) ?? new Set(); set.add(tool.split("__")[1] ?? ""); monthMcpServers.set(month, set); }
1013
+ if (tool === "Agent" || tool === "Task") agentSeen = true;
1014
+ // A VOLUME signal carries no unit reference: one reference per tool call was fifteen thousand a
1015
+ // month and pushed the staged reading past the door's request size (http 413, 2026-09-07).
1016
+ { const lineUnit = currentUnit; currentUnit = null; bump(month, "toolInitiator.agent"); currentUnit = lineUnit; }
1017
+ if (!toolFirstSeen.has(tool)) { toolFirstSeen.add(tool); bump(month, "firstUseEvent"); }
1018
+ if (tool.startsWith("mcp__")) { const server = `mcp:${tool.split("__")[1] ?? ""}`; if (!toolFirstSeen.has(server)) { toolFirstSeen.add(server); bump(month, "firstUseEvent"); } }
1019
+ if (tool === "Bash" && part.input?.run_in_background === true) bump(month, "backgroundRuns");
1020
+ if ((tool === "Edit" || tool === "Write" || tool === "MultiEdit") && /(\/\.claude\/|CLAUDE\.md|\.cursorrules|\/skills\/|\/agents\/)/.test(JSON.stringify(part.input?.file_path ?? ""))) bump(month, "agentImprovement");
1021
+ }
1022
+ if (part?.type === "text" && part.text) { answered = true; askText = part.text; }
1023
+ }
1024
+ // The run has ENDED when the model's last word on this line is text, not a tool call.
1025
+ const lastPart = [...parts].reverse().find((part) => part?.type === "text" || part?.type === "tool_use");
1026
+ if (lastPart?.type === "text" && lastPart.text) runFinished = true;
1027
+ if (answered) {
1028
+ if (opened) sawAnswerAfterOpening = true;
1029
+ lastWasAssistantAnswer = true;
1030
+ assistantAskedQuestion = /\?\s*$/.test(askText.trimEnd().slice(-200));
1031
+ assistantOffered = OFFER.test(askText.trimEnd().slice(-300));
1032
+ lastAnswerText = askText;
1033
+ }
1034
+ continue;
1035
+ }
1036
+ // ── v0.8: the failure → recovery chain, read off tool results (a failed run, then a clean one) ──
1037
+ if (line.type === "user" && Array.isArray(line.message?.content) && line.timestamp) {
1038
+ const monthR = String(line.timestamp).slice(0, 7);
1039
+ for (const part of line.message.content) {
1040
+ if (part?.type !== "tool_result") continue;
1041
+ runFinished = false; // a result waits for the model's next word
1042
+ const body = typeof part.content === "string" ? part.content : Array.isArray(part.content) ? part.content.map((c) => c?.text ?? "").join(" ") : "";
1043
+ const at = Date.parse(line.timestamp);
1044
+ {
1045
+ const run = part.tool_use_id ? pendingTests.get(String(part.tool_use_id)) : testQueue.shift();
1046
+ if (run) {
1047
+ if (part.tool_use_id) pendingTests.delete(String(part.tool_use_id));
1048
+ const lineUnit = currentUnit, lineDay = currentDay; currentUnit = run.unit; currentDay = run.day;
1049
+ if (inWindow(run.month)) bump(run.month, `testOutcome.${testOutcomeOf(body, part.is_error)}`);
1050
+ currentUnit = lineUnit; currentDay = lineDay;
1051
+ }
1052
+ }
1053
+ if (part.is_error === true) {
1054
+ const hash = createHash("sha256").update(body.toLowerCase().replace(/[0-9]+/g, "#").slice(0, 160)).digest("hex").slice(0, 16);
1055
+ if (inWindow(monthR) && recoveredHashes.has(hash)) bump(monthR, "sameErrorRecurrence");
1056
+ openFailure = { at, hash };
1057
+ } else if (openFailure) {
1058
+ if (inWindow(monthR) && Number.isFinite(at) && at - openFailure.at <= 3_600_000) bump(monthR, "fastRecoveries"); // within the hour (chapter 6: recovery latency, read as a count)
1059
+ recoveredHashes.add(openFailure.hash);
1060
+ openFailure = null;
1061
+ }
1062
+ }
1063
+ }
1064
+ // ── the person's own turns: lexical half + structure ──
1065
+ const turn = personTurn(line);
1066
+ if (!turn || !line.timestamp) continue;
1067
+ if (turn.interrupt) { interrupted = true; continue; } // the person pressed stop: the next turn redirects
1068
+ const text = turn.text;
1069
+ if (!text && !turn.slash) continue;
1070
+ if (!inWindow(String(line.timestamp).slice(0, 7))) continue;
1071
+ const month2 = String(line.timestamp).slice(0, 7);
1072
+ if (!sessionMonth) {
1073
+ sessionMonth = month2;
1074
+ if (projectDir && !projectFirstSeen.has(projectDir)) { projectFirstSeen.set(projectDir, month2); bump(month2, "newProjectStarts"); }
1075
+ }
1076
+ messages += 1;
1077
+ humanTurns += 1;
1078
+ if (line.timestamp) { lastTs = line.timestamp; if (!firstTs) firstTs = line.timestamp; }
1079
+ turnIndex += 1;
1080
+ // A human turn landing while a tool chain was mid-flight is a redirect by hand. v0.10: only while
1081
+ // the run was still going (its last word a tool call or a result) or after the person stopped it,
1082
+ // never after the model had finished with an answer.
1083
+ if ((runLength > 0 && !runFinished) || interrupted || turn.queued) bump(month2, "midRunRedirects");
1084
+ runLength = 0; interrupted = false; runFinished = !turn.queued; // a queued message lands inside a run that goes on
1085
+ if (turn.queued) lastWasAssistantAnswer = false; // typed during the run, not in answer to one
1086
+ // A slash command is the person calling a tool; a skill it names is context loaded, like the Skill tool.
1087
+ if (turn.slash) {
1088
+ bump(month2, "toolInitiator.person");
1089
+ if (skillTextOf(turn.slash, line.cwd ?? null)) { skillSeen = true; skillsLoaded.add(turn.slash); }
1090
+ if (!text) { lastWasAssistantAnswer = false; lastAnswerText = ""; continue; }
1091
+ }
1092
+ if (assistantAskedQuestion) { bump(month2, "agentQuestionsAnswered"); assistantAskedQuestion = false; }
1093
+ if (line.type === "user" && line.permissionMode && line.permissionMode !== "default") bump(month2, "autoModeShare");
1094
+ // The language layer: ONE rubric with a vocabulary per language, never one analysis per
1095
+ // language. Each turn is tagged by a stopword heuristic and counted — so the mirror can say
1096
+ // what share of the turns the rubric had words for, and no cross-person comparison is made
1097
+ // across languages before invariance is checked (framework 7.4, R13).
1098
+ const nlHits = (text.match(/\b(de|het|een|niet|ook|maar|dat|dit|ik|je|we|wel|nog|naar|bij)\b/gi) ?? []).length;
1099
+ const enHits = (text.match(/\b(the|and|not|this|that|with|for|you|we|but|also|from|have|are|is)\b/gi) ?? []).length;
1100
+ bump(month2, nlHits > enHits && nlHits >= 2 ? "turnsNl" : enHits >= 2 ? "turnsEn" : "turnsOther");
1101
+ // 7.3: a turn with special-category content is counted as a filter miss and nothing else.
1102
+ if ((LEAVE_OUT_RE && LEAVE_OUT_RE.test(text)) || SPECIAL_CATEGORY.test(text)) {
1103
+ bump(month2, "filterMiss");
1104
+ lastWasAssistantAnswer = false; lastAnswerText = ""; assistantOffered = false; chain = 0;
1105
+ continue;
1106
+ }
1107
+ // R12: does this turn directly follow the model's own offer, as a question or an acceptance?
1108
+ const induced = assistantOffered && lastWasAssistantAnswer && (/\?/.test(text) || ACCEPT.test(text));
1109
+ const afterAnswer = lastWasAssistantAnswer;
1110
+ const initiative = initiativeOf(lastAnswerText, afterAnswer);
1111
+ currentInduced = induced;
1112
+ if (induced) bump(month2, "modelInducedTurns");
1113
+ assistantOffered = false;
1114
+ if (!opened && copiedBeforeOpening) {
1115
+ // v0.10: a continuation of a session already counted from another file: its first new turn is not an opening.
1116
+ opened = true;
1117
+ } else if (!opened) {
1118
+ opened = true;
1119
+ const openingHash = text.toLowerCase().replace(/[0-9\s]+/g, " ").trim().slice(0, 80);
1120
+ if (openingHash.length > 20) {
1121
+ if (openingHashes.has(openingHash)) bump(month2, "templateReuse");
1122
+ openingHashes.add(openingHash);
1123
+ }
1124
+ if (/\bedge[ -]?cases?\b|\brandgeval|\bwhat if\b|\bwat als\b/i.test(text)) bump(month2, "edgeFirstOpenings");
1125
+ if (/\byou are (a|an|the)\b|\bjij bent (een|de)\b|\bact as\b|\bgedraag je als\b/i.test(text)) bump(month2, "roleFraming");
1126
+ // v0.8: the form the opening takes and the kind of act it is — one count per session, UNCLASSIFIED included.
1127
+ const form = classifyOpening(text);
1128
+ openingText = text;
1129
+ if (form === "ROLE") roleSentence = normaliseRole(sentencesOf(text)[0] ?? "");
1130
+ // The session's own unit id, so form, second turn and context join per session in the app
1131
+ // (the cross-tab is read within a context cell; the line's unit would scatter them).
1132
+ const lineUnit = currentUnit; currentUnit = sessionUnit;
1133
+ bump(month2, `openingForm.${form}`);
1134
+ bump(month2, `openingActKind.${openingActKind(text, form)}`);
1135
+ currentUnit = lineUnit;
1136
+ awaitingSecond = true;
1137
+ if (LABEL_OUT) labelRows.push({ unit: sessionUnit, month: month2, lang: nlHits > enHits && nlHits >= 2 ? "nl" : enHits >= 2 ? "en" : "other", form, text: text.slice(0, 240) });
1138
+ } else if (awaitingSecond && sawAnswerAfterOpening) {
1139
+ // v0.8: the second turn — what the opening earned — and the context that was loaded by then.
1140
+ const lineUnit = currentUnit; currentUnit = sessionUnit;
1141
+ bump(month2, `secondTurnType.${classifySecondTurn(text)}`);
1142
+ if (skillSeen || agentSeen) bump(month2, `contextMode.${skillSeen && agentSeen ? "SKILL_AGENT" : skillSeen ? "SKILL" : "AGENT"}`);
1143
+ currentUnit = lineUnit;
1144
+ awaitingSecond = false;
1145
+ }
1146
+ if (opened && sawAnswerAfterOpening && openingText !== null && text !== openingText) { turnsAfterOpening += 1; if (acceptedAtTurn === null && ACCEPT_TURN.test(text)) acceptedAtTurn = turnsAfterOpening; }
1147
+ if (pendingVerify && ACCEPT_TURN.test(text)) bump(month2, "verifyBeforeAccept");
1148
+ pendingVerify = /\bverif(y|ieer|icatie)\b|\bcontroleer\b|\bcheck (dat|of|whether|that|it)\b|\brun (the )?tests?\b|\bdraai de tests?\b/i.test(text);
1149
+ // v0.10: a verification asked for is closed by the next test run in the session, or by the next ask.
1150
+ if (verifyOpen) { const lineUnit = currentUnit, lineDay = currentDay; currentUnit = verifyOpen.unit; currentDay = verifyOpen.day; bump(verifyOpen.month, "verificationFollowed.NOT_RUN"); currentUnit = lineUnit; currentDay = lineDay; verifyOpen = null; }
1151
+ if (pendingVerify && !induced) verifyOpen = { unit: currentUnit, day: currentDay, month: month2 };
1152
+ if (!edited && /\b(we gaan voor|ik ga voor|ik kies( voor)?|besloten:|beslissing:|i'?m going with|we'?re going with|i'?ll go with|let'?s go with|decision:|decided:|we choose|i choose)\b/i.test(text)) bump(month2, "decisionBeforeCode");
1153
+ if (humanTurns <= 3 && /\bcost(s)?\b|\bkosten\b|\bbudget\b|\btoken.{0,12}(prijs|price|cost)\b|\bprijs per\b/i.test(text)) bump(month2, "costBeforeBuild");
1154
+ // A question re-asked after it was answered is a reassurance loop (4.1) — confirmation sought, not information.
1155
+ if (/\?/.test(text)) {
1156
+ const asked = text.toLowerCase().replace(/[^a-z0-9? ]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 120);
1157
+ if (asked.length > 24) {
1158
+ if (lastWasAssistantAnswer && askedBefore.has(asked)) bump(month2, "reassuranceLoops");
1159
+ askedBefore.add(asked);
1160
+ }
1161
+ }
1162
+ if (lastWasAssistantAnswer && /\?/.test(text) && !induced) {
1163
+ bump(month2, "followUpDepth");
1164
+ chain += 1;
1165
+ if (chain === 3) bump(month2, "deepFollowUpChains"); // once per chain, at the depth
1166
+ } else if (lastWasAssistantAnswer && !induced) {
1167
+ chain = 0;
1168
+ }
1169
+ lastWasAssistantAnswer = false; lastAnswerText = "";
1170
+ if (!edited) researchTurnsBeforeEdit += 1;
1171
+ if (/\b(security|privacy|AVG|GDPR|DPA|DPIA|encrypt|vulnerab|kwetsbaar|beveilig|compliance)\b/i.test(text)) bump(month2, "securityAttention");
1172
+ if (/(error TS\d|Traceback \(most recent call|^\s*at .+\(.+:\d+:\d+\)|Unhandled|Exception in|ERR_|error:.*\n.*at )/im.test(text)) bump(month2, "errorDrivenIterations");
1173
+ const fired = RULES.filter((rule) => rule.pattern.test(text)).map((rule) => rule.signal);
1174
+ for (const signal of fired) {
1175
+ if (induced && R12_SKIP.has(signal)) continue; // R12: the model's steering is not the person's framing, depth or pushback
1176
+ bump(month2, signal);
1177
+ const phase = PHASE_OF[signal];
1178
+ if (phase && !phaseFirst.has(phase)) phaseFirst.set(phase, turnIndex);
1179
+ }
1180
+ if (afterAnswer && CORRECT_MARK.test(text) && !phaseFirst.has("CORRECTION")) phaseFirst.set("CORRECTION", turnIndex);
1181
+ // v0.10: what a behaviour-bearing turn came after (R12's own flag stays on the unit), and what an acceptance added.
1182
+ if (fired.length > 0) bump(month2, `turnInitiative.${initiative}`);
1183
+ if (afterAnswer && ACCEPT_TURN.test(text)) bump(month2, `acceptanceForm.${fired.some((signal) => OWN_TERMS.has(signal)) ? "WITH_OWN_TERMS" : "AS_PROPOSED"}`);
1184
+ }
1185
+ // ── session-level: ran to completion with at most two human turns, and real delegation ──
1186
+ // A session that crosses a month boundary is counted in the month it STARTED; its unit's day
1187
+ // must be a date inside that month, so the first turn's day — never the last, which the door
1188
+ // rightly refused as "a date inside 2026-07" when the session ended on 1 August.
1189
+ const sessionDay = firstTs ? String(firstTs).slice(0, 10) : null;
1190
+ currentUnit = sessionUnit; currentInduced = false; currentDay = sessionDay && sessionMonth && sessionDay.startsWith(sessionMonth) ? sessionDay : null;
1191
+ // One session with a human turn is one independent chat for the confidence layer — sent as `stretches`, a number per month.
1192
+ if (sessionMonth && inWindow(sessionMonth) && humanTurns > 0) sessionsByMonth.set(sessionMonth, (sessionsByMonth.get(sessionMonth) ?? 0) + 1);
1193
+ if (sessionMonth && inWindow(sessionMonth) && humanTurns > 0 && humanTurns <= 2 && toolActions >= 20) bump(sessionMonth, "uninterruptedRuns");
1194
+ // Research before the first edit: three or more human turns spent reading and asking before
1195
+ // any file was touched — the session's opening was investigation, not construction.
1196
+ if (sessionMonth && inWindow(sessionMonth) && researchTurnsBeforeEdit >= 3 && edited) bump(sessionMonth, "researchBeforeBuild");
1197
+ if (sessionMonth && inWindow(sessionMonth) && humanTurns >= 15) bump(sessionMonth, "hardProblemPersistence");
1198
+ // v0.8: an opening that got an answer and nothing after it — the session ended there.
1199
+ if (sessionMonth && inWindow(sessionMonth) && awaitingSecond && sawAnswerAfterOpening) bump(sessionMonth, "secondTurnType.ABANDON");
1200
+ // v0.9: turns to the first acceptance, bucketed; and the skill matrix of this session.
1201
+ if (sessionMonth && inWindow(sessionMonth) && opened && openingText !== null && sawAnswerAfterOpening) {
1202
+ bump(sessionMonth, `turnsToAccept.${acceptedAtTurn === null ? "NONE" : acceptedAtTurn === 1 ? "ONE" : acceptedAtTurn === 2 ? "TWO" : "THREE_PLUS"}`);
1203
+ const cwd = currentContext ? contextCwds.get(currentContext) ?? null : null;
1204
+ if (skillsLoaded.size > 0 && openingText) {
1205
+ const texts = [...skillsLoaded].map((name) => skillTextOf(name, cwd)).filter(Boolean);
1206
+ const restated = texts.reduce((most, skillText) => Math.max(most, restatedSentences(openingText, skillText)), 0);
1207
+ if (restated > 0) bump(sessionMonth, "contextRestated", restated);
1208
+ if (roleSentence && texts.length > 0 && texts.every((skillText) => sentencesOf(skillText).map(wordsOf).every((set) => jaccard(wordsOf(roleSentence), set) < DRIFT_OVERLAP))) bump(sessionMonth, "patternChange");
1209
+ }
1210
+ if (roleSentence && skillsLoaded.size === 0) {
1211
+ const byPattern = rolePatterns.get(sessionMonth) ?? new Map();
1212
+ const roleKey = fingerprint(roleSentence);
1213
+ byPattern.set(roleKey, new Set([...(byPattern.get(roleKey) ?? []), sessionUnit]));
1214
+ rolePatterns.set(sessionMonth, byPattern);
1215
+ }
1216
+ }
1217
+ if (sessionMonth && inWindow(sessionMonth) && firstTs && lastTs) sessionSpans.push({ start: Date.parse(firstTs), end: Date.parse(lastTs), month: sessionMonth, unit: sessionUnit, day: currentDay, context: currentContext });
1218
+ if (streamHash && streamHash.digest("hex") !== manifest[sessionIndex - 1]) throw Error("Source changed during counting; rerun to resume safely");
1219
+ // v0.10: the session's order of phases, and a requested verification still open at its end.
1220
+ if (sessionMonth && inWindow(sessionMonth) && humanTurns > 0) bump(sessionMonth, `taskSequence.${taskSequenceOf(phaseFirst)}`);
1221
+ if (verifyOpen && sessionMonth && inWindow(sessionMonth)) { currentUnit = verifyOpen.unit; currentDay = verifyOpen.day; bump(verifyOpen.month, "verificationFollowed.NOT_RUN"); }
1222
+ checkpoint?.write(sessionIndex,{maps:checkpointMaps,sets:checkpointSets,sessionSpans,messages,files,currentContext,duplicateLines},checkpointDependencies);
1223
+ if(++processed>=maxSessions && sessionIndex<manifest.length){
1224
+ console.log(`Counter paused at ${sessionIndex}/${manifest.length} sessions; rerun the same command. Nothing sent.`);
1225
+ checkpoint?.close();process.exit(0);
1226
+ }
1227
+ }
1228
+ currentUnit = null; currentDay = null; // month-level aggregates carry no unit and no day
1229
+ for (const [month, set] of monthToolNames) bump(month, "toolBreadth", set.size);
1230
+ for (const [month, set] of monthMcpServers) if (set.size > 0) bump(month, "mcpServersDistinct", set.size);
1231
+ for (const [month, byPattern] of rolePatterns) for (const sessions of byPattern.values()) if (sessions.size >= RECURRENCE_FLOOR) bump(month, "recurringPatternCandidate");
1232
+ // The git side, per project directory a transcript named: the person's own commits inside the window.
1233
+ let gitSideCommits = 0;
1234
+ for (const [context, cwd] of contextCwds) {
1235
+ if (excluded(cwd)) continue;
1236
+ const perCommit = gitSideCounts(gitCommitsOf(cwd, floor.toISOString()));
1237
+ for (const commit of perCommit) {
1238
+ const month = commit.at.slice(0, 7);
1239
+ if (!inWindow(month)) continue;
1240
+ gitSideCommits += 1;
1241
+ currentUnit = unitOf(cwd, commit.sha); currentDay = commit.at.slice(0, 10); currentContext = context; currentInduced = false;
1242
+ if (commit.testFirstOrder > 0) bump(month, "testFirstOrder", commit.testFirstOrder);
1243
+ if (commit.adrWritten > 0) bump(month, "adrWritten", commit.adrWritten);
1244
+ }
1245
+ }
1246
+ currentUnit = null; currentDay = null; currentContext = null;
1247
+ // The same strict overlap test in O(n log n), rather than comparing every pair.
1248
+ export function overlappingSessions(spans) {
1249
+ const ordered=spans.map((span,index)=>({...span,index})).filter(s=>s.end>s.start).sort((a,b)=>a.start-b.start);
1250
+ const found=new Set();let maximumEnd=-Infinity;
1251
+ for(let i=0;i<ordered.length;i++){
1252
+ const a=ordered[i];
1253
+ if(maximumEnd>a.start || (ordered[i+1] && ordered[i+1].start<a.end))found.add(a.index);
1254
+ maximumEnd=Math.max(maximumEnd,a.end);
1255
+ }
1256
+ return found;
1257
+ }
1258
+ const overlapping=overlappingSessions(sessionSpans);
1259
+ // Parallelism: a session that overlaps any other counted session, once per session.
1260
+ for (let i = 0; i < sessionSpans.length; i += 1) {
1261
+ const a = sessionSpans[i];
1262
+ if (a.end <= a.start) continue;
1263
+ 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; }
1264
+ }
1265
+
1266
+ const months = [...byMonth.keys()].sort();
1267
+ console.log(`${ANALYZER_VERSION} · ${files} transcript file(s), ${messages} own messages, ${months.length} month(s) with counts`);
1268
+ console.log(` projects counted: ${countedDirs.length}${countedDirs.length ? ` (${countedDirs.join(", ")})` : ""}`);
1269
+ console.log(` git side: ${gitSideCommits} own commit(s) read for test-first order and decision records (paths stay here)`);
1270
+ console.log(` lines read once: ${duplicateLines} line(s) already counted from another transcript file were skipped${unreadableLines ? ` · ${unreadableLines} line(s) too long to read` : ""}`);
1271
+ if (copilotSessionCount > 0) console.log(` copilot chat sessions read: ${copilotSessionCount} (VS Code workspaceStorage, this machine)`);
1272
+ if (codexRolloutCount > 0) console.log(` codex rollouts read: ${codexRolloutCount} (~/.codex/sessions, this machine; tokens once per response, the person's turns only)`);
1273
+ if (EXPORTS.length > 0) console.log(` claude.ai export: ${exportConversations} conversation(s) counted${skippedConversations.length ? ` · excluded, never read: ${skippedConversations.length}` : ""}`);
1274
+ if (skippedDirs.length > 0) console.log(` excluded, never read: ${skippedDirs.join(", ")}`);
1275
+ for (const month of months) {
1276
+ const bucket = byMonth.get(month);
1277
+ console.log(` ${month}: ${[...bucket.entries()].sort((a, b) => b[1] - a[1]).map(([signal, count]) => `${signal} ${count}`).join(" · ")}`);
1278
+ console.log(` units: ${unitsByMonth.get(month)?.size ?? 0} references by id (16-hex digests of file and line — never text) · ${byMonthContext.get(month)?.size ?? 0} cells by context`);
1279
+ }
1280
+ /**
1281
+ * Automatic naming without a name travelling: a second digest per context, of the directory's
1282
+ * normalised LAST segment ("davosnl-com", "dlg-platform") — the same normalisation the app
1283
+ * applies to every repository name the record already holds. A match names the column in the
1284
+ * app; the name itself is printed here and goes nowhere.
1285
+ */
1286
+ const sharedSegments = () => { const dirs = [...contextNames.values()].map((d) => d.split("-").filter(Boolean)); let shared = 0; while (dirs.length > 1 && dirs.every((d) => d.length > shared + 1 && d[shared] === dirs[0][shared])) shared += 1; return shared; };
1287
+ const normalise = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1288
+ /** The project's real name where the transcript said it (cwd); the directory key past the shared home path otherwise. */
1289
+ const slugOf = (context, dir) => normalise(contextBasenames.get(context) ?? dir.split("-").filter(Boolean).slice(sharedSegments()).join("-"));
1290
+ const slugHash = (context, dir) => createHash("sha256").update(`name:${slugOf(context, dir)}`).digest("hex").slice(0, 16);
1291
+ const contextNamesOf = () => [...contextNames.entries()].map(([context, dir]) => ({ context, slug: slugHash(context, dir) }));
1292
+ if (contextNames.size > 0) {
1293
+ console.log(` contexts (a digest per project — the name below stays on this machine; give each its name on /behaviour/matrix):`);
1294
+ // The directories share the home path as a prefix; print what differs, so "dlg-platform" and
1295
+ // "shift-recovery-mobile" stay distinguishable — the names never leave this console.
1296
+ for (const [digest, dir] of contextNames) console.log(` ${digest} ${contextBasenames.get(digest) ?? dir.split("-").filter(Boolean).slice(sharedSegments()).join("-")}`);
1297
+ console.log(` (the app names a context by itself when its slug digest matches a repository the record already holds)`);
1298
+ }
1299
+ console.log("");
1300
+ console.log("These counts sketch HOW you work with AI — your own mirror, owned by you. The more");
1301
+ console.log("months, the truer the picture. No text ever leaves this machine: the door accepts only");
1302
+ console.log("numbers, so secrets and client data have no channel to travel through. You keep control:");
1303
+ console.log("--exclude before counting, erase per reading on /behaviour, revoke the coupling any time.");
1304
+ /**
1305
+ * F-04 — the send needs a human. The reading about to be sent is digested to 16 hex (its
1306
+ * rubric, months, counts and unit ids — the same input gives the same digest); the dry run
1307
+ * prints an approval link for the OWNER to open in the app, in their own browser session. The
1308
+ * door accepts the send only when that approval exists. An agent can pass --send; it cannot
1309
+ * click as the person.
1310
+ */
1311
+ const cellsOf = (month) => [...(byMonthContext.get(month)?.entries() ?? [])].map(([key, count]) => { const [signal, context] = key.split("|"); return { signal, context, count }; });
1312
+ const bundle = months.map((month) => ({ month, signals: [...byMonth.get(month).entries()].sort(), contexts: cellsOf(month).map((c) => `${c.signal}|${c.context}|${c.count}`).sort(), units: [...(unitsByMonth.get(month)?.keys() ?? [])].sort() }));
1313
+ const namesDigest = contextNamesOf().map((n) => `${n.context}|${n.slug}`).sort();
1314
+ const APPROVAL = createHash("sha256").update(JSON.stringify({ version: ANALYZER_VERSION, bundle, names: namesDigest })).digest("hex").slice(0, 16);
1315
+ const appOrigin = URL_ ? new URL(URL_).origin : "<app-url>";
1316
+ console.log("");
1317
+ if (LABEL_OUT) { writeFileSync(LABEL_OUT, labelRows.map((row) => JSON.stringify(row)).join("\n") + "\n"); console.log(` openings written for labelling: ${labelRows.length} → ${LABEL_OUT} (local file; add "label" per line, then --agreement ${LABEL_OUT})`); }
1318
+ if (DRY) {
1319
+ console.log(`dry run — nothing sent. Next: run again with --stage — the reading then WAITS in the app (${appOrigin}/behaviour) until the person clicks Import; nothing is counted before that click. (Alternative without the app visit: approval code ${APPROVAL} at ${appOrigin}/behaviour?approve=${APPROVAL}, then --send.)${URL_ && TOKEN ? ` Coupling found (${new URL(URL_).host}).` : " Pass --url and --token (or couple this client) first."}`);
1320
+ process.exit(0);
1321
+ }
1322
+ if (STAGE) {
1323
+ if (!URL_ || !TOKEN) { console.error("no coupling found: pass --url and --token, or set WORKTRUST_MCP_URL / WORKTRUST_MCP_TOKEN"); process.exit(1); }
1324
+ const readings = months.map((month) => ({ period: month, stretches: sessionsByMonth.get(month) ?? 0, signals: [...byMonth.get(month).entries()].map(([signal, count]) => ({ signal, count: clampCount(signal, count, month) })), contexts: cellsOf(month), context_names: contextNamesOf(), units: [...(unitsByMonth.get(month)?.values() ?? [])].map(({ unit, signal, induced, day, context }) => ({ unit, signal, induced, ...(day ? { day } : {}), ...(context ? { context } : {}) })) }));
1325
+ if (!flag("legacy-stage")) {
1326
+ // WHAT THIS MACHINE ALREADY DELIVERED, per month and ISO week, for this coupling and rubric
1327
+ // (owner, 2026-09-27: only what was not added before). A week's digest covers its counts and
1328
+ // the unit references dated in it; a month's "rest" covers what has no week (its stretches,
1329
+ // its per-project cells). A month travels only when one of those moved, and it travels with
1330
+ // the list of weeks that moved and by how much, so the Import window can say what changed.
1331
+ // Another coupling (another machine) or another rubric is another ledger: everything travels
1332
+ // once. --full sends every month in the window regardless.
1333
+ const base=value("checkpoint")??join(homedir(),".worktrust","counter-checkpoint.json");
1334
+ const deliveryPath=base+".delivery", ledgerPath=base+".weeks";
1335
+ mkdirSync(dirname(deliveryPath),{recursive:true,mode:0o700});
1336
+ const scope=fingerprint({url:URL_,token:TOKEN,version:ANALYZER_VERSION});
1337
+ const writeAtomic=(path,value)=>{const temp=path+"."+process.pid+".tmp";writeFileSync(temp,JSON.stringify(value),{mode:0o600});renameSync(temp,path);};
1338
+ let ledger=null;try{ledger=JSON.parse(readFileSync(ledgerPath,"utf8"));}catch{}
1339
+ if(ledger?.scope!==scope||flag("full"))ledger={scope,weeks:{},rest:{}};
1340
+ const sumOf=(bag)=>[...bag.values()].reduce((sum,n)=>sum+n,0);
1341
+ const weeksOf=(month)=>[...byWeek.entries()].filter(([key])=>key.startsWith(month+"|")).map(([key,bag])=>{
1342
+ const week=key.slice(month.length+1);
1343
+ const units=[...(unitsByMonth.get(month)?.values()??[])].filter(u=>(u.day&&u.day.startsWith(month)?isoWeek(u.day):"U")===week).map(u=>`${u.unit}|${u.signal}|${u.induced?1:0}|${u.context??""}`).sort();
1344
+ return {key,week,observations:sumOf(bag),digest:fingerprint({signals:[...bag.entries()].sort(),units})};
1345
+ });
1346
+ const restOf=(reading)=>fingerprint({stretches:reading.stretches,contexts:reading.contexts.map(c=>`${c.signal}|${c.context}|${c.count}`).sort(),names:reading.context_names.map(n=>`${n.context}|${n.slug}`).sort()});
1347
+ // Months delivered before and absent now (their sessions were deleted) travel empty, so the
1348
+ // door removes what the machine no longer holds.
1349
+ const delivered=new Set([...Object.keys(ledger.weeks).map(key=>key.split("|")[0]),...Object.keys(ledger.rest)]);
1350
+ for(const period of delivered)if(inWindow(period)&&!readings.some(r=>r.period===period))readings.push({period,stretches:0,signals:[],contexts:[],context_names:[],units:[]});
1351
+ const plan=[];
1352
+ for(const reading of readings.sort((x,y)=>x.period.localeCompare(y.period))){
1353
+ const weeks=weeksOf(reading.period), rest=restOf(reading);
1354
+ const before=Object.entries(ledger.weeks).filter(([key])=>key.startsWith(reading.period+"|"));
1355
+ const changes=[
1356
+ ...weeks.filter(w=>ledger.weeks[w.key]?.digest!==w.digest).map(w=>({week:w.week,observations:w.observations,previous:ledger.weeks[w.key]?.observations??null})),
1357
+ ...before.filter(([key])=>!weeks.some(w=>w.key===key)).map(([key,old])=>({week:key.slice(reading.period.length+1),observations:0,previous:old.observations})),
1358
+ ].sort((x,y)=>x.week.localeCompare(y.week));
1359
+ if(changes.length===0&&ledger.rest[reading.period]===rest)continue;
1360
+ plan.push({reading,weeks,rest,changes});
1361
+ }
1362
+ const unchanged=new Set(readings.map(r=>r.period)).size-plan.length;
1363
+ console.log(` ${plan.length} month(s) with new or changed weeks${unchanged>0?` · ${unchanged} unchanged, not sent`:""}`);
1364
+ for(const {reading,changes} of plan)console.log(` ${reading.period}: ${changes.length?changes.map(c=>`${c.week} ${c.previous===null?"new":c.observations===0?"removed":`${c.previous}→${c.observations}`}`).join(" · "):"per-project cells changed"}`);
1365
+ if(plan.length===0){console.log("Nothing new since the last delivery from this machine. Nothing sent.");process.exit(0);}
1366
+ // Persist the outbound revision BEFORE sending. A failed HTTP response replays safely.
1367
+ let delivery=null;try{delivery=JSON.parse(readFileSync(deliveryPath,"utf8"));}catch{}
1368
+ if(delivery?.scope!==scope)delivery=null;
1369
+ const digest=fingerprint(plan.map(p=>p.reading));
1370
+ const revision=delivery?.digest===digest?delivery.revision:Math.max(Date.now(),(delivery?.revision??0)+1);
1371
+ writeAtomic(deliveryPath,{scope,digest,revision,periods:plan.map(p=>p.reading.period)});
1372
+ for(const {reading,weeks,rest,changes} of plan){
1373
+ const total=Math.max(1,Math.ceil(reading.units.length/4000),Math.ceil(reading.contexts.length/1000),Math.ceil(reading.context_names.length/500));
1374
+ if(total>1000)throw Error("Month exceeds batch capacity; no truncated reading was sent");
1375
+ for(let part=0;part<total;part++){
1376
+ const chunk={...reading,contexts:reading.contexts.slice(part*1000,(part+1)*1000),context_names:reading.context_names.slice(part*500,(part+1)*500),units:reading.units.slice(part*4000,(part+1)*4000)};
1377
+ const payload={jsonrpc:"2.0",id:"batch",method:"tools/call",params:{name:"stage_signals",arguments:{analyzer_version:ANALYZER_VERSION,payload_hash:fingerprint(chunk).slice(0,16),batch:{revision,part,total},readings:[chunk],...(part===0?{changes:changes.slice(0,60)}:{})}}};
1378
+ const body=JSON.stringify(payload);if(Buffer.byteLength(body)>STAGE_MAX_BYTES)throw Error("Batch too large; nothing truncated");
1379
+ const response=await fetch(URL_,{method:"POST",headers:{"content-type":"application/json",authorization:`Bearer ${TOKEN}`,...proofFor(URL_,body)},body});
1380
+ const answer=await response.json();
1381
+ if(!response.ok||answer.error||answer.result?.isError)throw Error("Batch not acknowledged; rerun to resume");
1382
+ const status=JSON.parse(answer.result.content[0].text);
1383
+ console.log(` batch ${reading.period} ${part+1}/${total}: ${status.status}`);
1384
+ if(status.status==="imported"||status.status==="unchanged"||status.status==="pending"){
1385
+ // Acknowledged: this month's weeks are now what the door holds (or offers) from here.
1386
+ for(const key of Object.keys(ledger.weeks))if(key.startsWith(reading.period+"|"))delete ledger.weeks[key];
1387
+ for(const w of weeks)ledger.weeks[w.key]={digest:w.digest,observations:w.observations};
1388
+ if(weeks.length||reading.signals.length)ledger.rest[reading.period]=rest;else delete ledger.rest[reading.period];
1389
+ writeAtomic(ledgerPath,ledger);
1390
+ break;
1391
+ }
1392
+ }
1393
+ }
1394
+ console.log("Complete months await the owner's Import in /behaviour. Imports replace this coupling's month, including corrections and removals.");
1395
+ process.exit(0);
1396
+ }
1397
+ // UNDER THE HOST'S CEILING. A serverless request takes about 4.5 MB; a year of unit references
1398
+ // does not fit in one. Months are staged in chunks that stay under the ceiling, each with its own
1399
+ // dedupe hash (the pending row is keyed on it, and a second chunk under the same hash would be
1400
+ // dropped as a duplicate). The person imports each chunk with one click; nothing is on the mirror
1401
+ // before that.
1402
+ const chunks = [];
1403
+ for (const reading of readings) {
1404
+ const last = chunks[chunks.length - 1];
1405
+ if (last && JSON.stringify([...last, reading]).length <= STAGE_MAX_BYTES) last.push(reading); else chunks.push([reading]);
1406
+ }
1407
+ for (const chunk of chunks) {
1408
+ const hash = createHash("sha256").update(`${APPROVAL}:${chunk.map((r) => r.period).join(",")}`).digest("hex").slice(0, 16);
1409
+ const payload = { jsonrpc: "2.0", id: "stage", method: "tools/call", params: { name: "stage_signals", arguments: { analyzer_version: ANALYZER_VERSION, payload_hash: hash, readings: chunk } } };
1410
+ const body = JSON.stringify(payload);
1411
+ const response = await fetch(URL_, { method: "POST", headers: { "content-type": "application/json", "user-agent": `worktrust-counter/${ANALYZER_VERSION}`, authorization: `Bearer ${TOKEN}`, ...proofFor(URL_, body) }, body });
1412
+ const answer = await response.json().catch(() => null);
1413
+ const text = answer?.result?.content?.[0]?.text ?? answer?.error?.message ?? (response.status === 413 ? "http 413 — the request was still too large for the host; lower STAGE_MAX_BYTES" : `http ${response.status}`);
1414
+ console.log(` staged ${chunk[0].period}${chunk.length > 1 ? `–${chunk[chunk.length - 1].period}` : ""} → ${text}`);
1415
+ }
1416
+ console.log(` the person imports ${chunks.length === 1 ? "it" : `each of the ${chunks.length} readings`} at ${appOrigin}/behaviour — one click each; nothing is on the mirror before it.`);
1417
+ process.exit(0);
1418
+ }
1419
+ if (!URL_ || !TOKEN) { console.error("no coupling found: pass --url and --token, or set WORKTRUST_MCP_URL / WORKTRUST_MCP_TOKEN"); process.exit(1); }
1420
+ console.log(`sending to ${new URL(URL_).host} …`);
1421
+ for (const month of months) {
1422
+ const bucket = byMonth.get(month);
1423
+ const units = [...(unitsByMonth.get(month)?.values() ?? [])].map(({ unit, signal, induced, day, context }) => ({ unit, signal, induced, ...(day ? { day } : {}), ...(context ? { context } : {}) }));
1424
+ const payload = { jsonrpc: "2.0", id: month, method: "tools/call", params: { name: "log_signals", arguments: { period: month, stretches: sessionsByMonth.get(month) ?? 0, analyzer_version: ANALYZER_VERSION, signals: [...bucket.entries()].map(([signal, count]) => ({ signal, count: clampCount(signal, count, month) })), contexts: cellsOf(month), context_names: contextNamesOf(), units, approval: APPROVAL } } };
1425
+ const body = JSON.stringify(payload);
1426
+ const response = await fetch(URL_, { method: "POST", headers: { "content-type": "application/json", "user-agent": `worktrust-counter/${ANALYZER_VERSION}`, authorization: `Bearer ${TOKEN}`, ...proofFor(URL_, body) }, body });
1427
+ const answer = await response.json().catch(() => null);
1428
+ const text = answer?.result?.content?.[0]?.text ?? answer?.error?.message ?? `http ${response.status}`;
1429
+ console.log(` sent ${month} → ${text}`);
1430
+ }