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.
- package/LICENSE +21 -0
- package/README.md +99 -2
- package/SECURITY.md +42 -0
- package/count-behaviour.mjs +1430 -0
- package/log-session.mjs +858 -0
- package/package.json +41 -4
- package/setup-mcp.mjs +140 -0
- package/worktrust.mjs +470 -0
|
@@ -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
|
+
}
|