@bglocation/tune-context 1.0.1 → 2.0.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/.claude-plugin/marketplace.json +2 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +252 -0
- package/README.md +129 -16
- package/bin/tune-context +11 -0
- package/bin/tune-context.mjs +110 -16
- package/cli/detect.mjs +92 -16
- package/cli/generate.mjs +48 -0
- package/cli/sync-doctrine.mjs +11 -2
- package/cli/verify.mjs +119 -3
- package/eval/adoption-report.mjs +211 -33
- package/hooks/adoption-log.mjs +2 -2
- package/hooks/config-dir.mjs +12 -0
- package/hooks/context-reminder.mjs +212 -0
- package/hooks/hooks.json +11 -0
- package/hooks/pre-compact-snapshot.mjs +69 -7
- package/package.json +4 -2
- package/skills/token-efficiency/SKILL.md +3 -1
- package/skills/tune-context/SKILL.md +94 -19
- package/skills/tune-context/templates/settings.json.tmpl +15 -14
package/eval/adoption-report.mjs
CHANGED
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
// measures *behavior* (a proxy), not dollars. OTEL is the opt-in complement.
|
|
12
12
|
// • caveman can't be measured: it's an output style, not a tool call.
|
|
13
13
|
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
-
import { homedir } from 'node:os';
|
|
15
14
|
import { join } from 'node:path';
|
|
16
15
|
import { parseArgs } from 'node:util';
|
|
16
|
+
import { claudeBaseDir } from '../hooks/config-dir.mjs';
|
|
17
17
|
|
|
18
18
|
const DOCTRINE_SKILLS = new Set([
|
|
19
19
|
'token-efficiency',
|
|
@@ -22,8 +22,39 @@ const DOCTRINE_SKILLS = new Set([
|
|
|
22
22
|
'phase-workflow',
|
|
23
23
|
]);
|
|
24
24
|
|
|
25
|
+
// Minimum distinct sessions before the verdict prints a hard ✓/✗ for the
|
|
26
|
+
// cross-session levers (RAG, doctrine skills). Below it those judgments are
|
|
27
|
+
// withheld ("insufficient signal") — one atypical session can't masquerade as a
|
|
28
|
+
// trend. FROZEN WITH PO (2026-07-24, TASK-070): the model must not choose the
|
|
29
|
+
// sample size at which its own sessions escape a verdict (anti-bias, TASK-062).
|
|
30
|
+
// Churn is exempt — a within-session symptom, readable at any n.
|
|
31
|
+
const MIN_SESSIONS_FOR_VERDICT = 3;
|
|
32
|
+
|
|
33
|
+
// Two hook processes spawned for the SAME Claude Code event (a script
|
|
34
|
+
// registered on both the plugin channel and settings.json — TASK-072) write
|
|
35
|
+
// near-identical records milliseconds apart. FROZEN WITH PO (2026-07-25,
|
|
36
|
+
// TASK-072; same anti-bias rule as MIN_SESSIONS_FOR_VERDICT above, TASK-062).
|
|
37
|
+
//
|
|
38
|
+
// Measured, not guessed: the gap distribution of same-key adjacent pairs in
|
|
39
|
+
// the real 2026-07-24 log is cleanly bimodal —
|
|
40
|
+
// ≤14ms : 159 pairs (duplicate dispatch: 0,1,2,3,4,5,6,7,8,14ms)
|
|
41
|
+
// 15-359ms: 0 pairs (empty)
|
|
42
|
+
// ≥360ms : 288 pairs (continuous out to 10s — genuine repeated activity)
|
|
43
|
+
// 100ms sits in the empty band, with maximum margin on both sides. A wider
|
|
44
|
+
// window (the 1000ms first proposed here) reaches past the gap and swallows 8
|
|
45
|
+
// real pairs — suppressing true churn, the exact failure this must avoid.
|
|
46
|
+
const DUPLICATE_LOG_WINDOW_MS = 100;
|
|
47
|
+
|
|
48
|
+
// Share of all records that must look like duplicate dispatches before the
|
|
49
|
+
// churn verdict is withheld. FROZEN WITH PO (2026-07-25, TASK-072).
|
|
50
|
+
// A doubled registration doubles EVERY event, so the signature is always a
|
|
51
|
+
// large share (28% in the real log — 159 pairs of 559 records), never a
|
|
52
|
+
// stray one. Requiring a share rather than a single pair keeps a couple of
|
|
53
|
+
// coincidentally-fast events from silencing a whole log's verdict.
|
|
54
|
+
const MIN_DUPLICATE_SHARE = 0.1;
|
|
55
|
+
|
|
25
56
|
function defaultLogPath() {
|
|
26
|
-
return join(
|
|
57
|
+
return join(claudeBaseDir(), 'tune-context', 'adoption.jsonl');
|
|
27
58
|
}
|
|
28
59
|
|
|
29
60
|
const USAGE = `
|
|
@@ -33,7 +64,8 @@ Usage:
|
|
|
33
64
|
adoption-report [--log <path>]
|
|
34
65
|
|
|
35
66
|
Options:
|
|
36
|
-
--log <path> Path to adoption.jsonl (default:
|
|
67
|
+
--log <path> Path to adoption.jsonl (default: <config dir>/tune-context/adoption.jsonl,
|
|
68
|
+
where <config dir> is $CLAUDE_CONFIG_DIR if set, else ~/.claude)
|
|
37
69
|
-h, --help Show this help
|
|
38
70
|
|
|
39
71
|
The log is written by the adoption-log.mjs PostToolUse hook (installed by
|
|
@@ -55,11 +87,53 @@ export function parseLog(text) {
|
|
|
55
87
|
return out;
|
|
56
88
|
}
|
|
57
89
|
|
|
90
|
+
// Two (or more) records sharing (session, tool, lever, detail) within
|
|
91
|
+
// DUPLICATE_LOG_WINDOW_MS of each other look like the SAME real event logged
|
|
92
|
+
// twice — the signature of a hook registered on two channels at once
|
|
93
|
+
// (TASK-072), not genuine repeated activity. Runs over every lever, not just
|
|
94
|
+
// reads: a doubled hook doubles all of them equally.
|
|
95
|
+
//
|
|
96
|
+
// Records with an empty `detail` are SKIPPED, and skipped on both sides of
|
|
97
|
+
// the ratio (see `assessable`). classify() gives every non-search Bash call
|
|
98
|
+
// `detail: null`, so they all collapse onto a single key — and Claude Code
|
|
99
|
+
// fires parallel tool calls within one turn, whose hooks land milliseconds
|
|
100
|
+
// apart. Two genuinely different Bash commands would then read as a duplicate
|
|
101
|
+
// pair. Measured on a clean simulated log, parallel Bash alone reached 13.9%
|
|
102
|
+
// and tripped MIN_DUPLICATE_SHARE — a false "undecidable" over a perfectly
|
|
103
|
+
// readable log. Restricting to detail-bearing records removes that vector
|
|
104
|
+
// (0% on the same simulation) and still detects the real thing with a wide
|
|
105
|
+
// margin: 28.9% on the frozen snapshot, 42.8% on a 1605-record live log.
|
|
106
|
+
// Deduplicating the counts themselves stays out of scope (TASK-072): we
|
|
107
|
+
// withhold the verdict, we don't guess which record was "real".
|
|
108
|
+
function countDuplicateRegistrationPairs(records) {
|
|
109
|
+
const groups = new Map();
|
|
110
|
+
let assessable = 0;
|
|
111
|
+
for (const r of records) {
|
|
112
|
+
const t = Date.parse(r.ts);
|
|
113
|
+
if (Number.isNaN(t)) continue;
|
|
114
|
+
if (!r.detail) continue; // ambiguous key — see above
|
|
115
|
+
assessable++;
|
|
116
|
+
const key = `${r.session ?? ''}\u0000${r.tool ?? ''}\u0000${r.lever ?? ''}\u0000${r.detail}`;
|
|
117
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
118
|
+
groups.get(key).push(t);
|
|
119
|
+
}
|
|
120
|
+
let pairs = 0;
|
|
121
|
+
for (const times of groups.values()) {
|
|
122
|
+
times.sort((a, b) => a - b);
|
|
123
|
+
for (let i = 1; i < times.length; i++) {
|
|
124
|
+
if (times[i] - times[i - 1] <= DUPLICATE_LOG_WINDOW_MS) pairs++;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { pairs, assessable };
|
|
128
|
+
}
|
|
129
|
+
|
|
58
130
|
export function aggregate(records) {
|
|
59
|
-
const search = { rag: 0,
|
|
131
|
+
const search = { rag: 0, grepTool: 0, shell: 0, glob: 0 };
|
|
60
132
|
const skills = new Map();
|
|
61
133
|
const subagents = new Map();
|
|
62
|
-
const readCounts = new Map(); //
|
|
134
|
+
const readCounts = new Map(); // `${session}\u0000${file}` -> times read in that session
|
|
135
|
+
const sessionSet = new Set();
|
|
136
|
+
const cwdSet = new Set();
|
|
63
137
|
let reads = 0;
|
|
64
138
|
let edits = 0;
|
|
65
139
|
let bash = 0;
|
|
@@ -68,12 +142,24 @@ export function aggregate(records) {
|
|
|
68
142
|
const bump = (m, k) => m.set(k, (m.get(k) ?? 0) + 1);
|
|
69
143
|
|
|
70
144
|
for (const r of records) {
|
|
145
|
+
if (r.session != null) sessionSet.add(r.session);
|
|
146
|
+
if (r.cwd != null) cwdSet.add(r.cwd);
|
|
71
147
|
switch (r.lever) {
|
|
72
|
-
case 'search':
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
148
|
+
case 'search': {
|
|
149
|
+
// Split content search by TOOL — the only reliable "is this a codebase
|
|
150
|
+
// search?" signal. RAG (search_codebase) and the Grep tool are
|
|
151
|
+
// unambiguous code search; a grep/find inside Bash is usually plumbing
|
|
152
|
+
// (git … | grep, npm pack | grep) and must NOT compete with RAG. The
|
|
153
|
+
// record's `tool` field (set by the logger's buildRecord) carries this,
|
|
154
|
+
// so the split reinterprets existing logs — no format change, hook
|
|
155
|
+
// untouched. See TASK-069.
|
|
156
|
+
const tool = typeof r.tool === 'string' ? r.tool : '';
|
|
157
|
+
if (r.detail === 'rag' || tool.includes('search_codebase')) search.rag++;
|
|
158
|
+
else if (r.detail === 'glob' || tool === 'Glob') search.glob++;
|
|
159
|
+
else if (tool === 'Grep') search.grepTool++;
|
|
160
|
+
else search.shell++; // Bash-grep / ambiguous — reported, excluded from RAG-share
|
|
76
161
|
break;
|
|
162
|
+
}
|
|
77
163
|
case 'skill':
|
|
78
164
|
bump(skills, r.detail ?? '(unknown)');
|
|
79
165
|
break;
|
|
@@ -82,7 +168,11 @@ export function aggregate(records) {
|
|
|
82
168
|
break;
|
|
83
169
|
case 'read':
|
|
84
170
|
reads++;
|
|
85
|
-
|
|
171
|
+
// Key re-read churn by (session, file): the same file opened in two
|
|
172
|
+
// independent sessions is NOT lost context — only re-opening within one
|
|
173
|
+
// session is. Per-file-global inflates churn on multi-session logs
|
|
174
|
+
// (TASK-070). Single-session logs are unchanged (session is constant).
|
|
175
|
+
if (r.detail) bump(readCounts, `${r.session ?? ''}\u0000${r.detail}`);
|
|
86
176
|
break;
|
|
87
177
|
case 'edit':
|
|
88
178
|
edits++;
|
|
@@ -95,8 +185,11 @@ export function aggregate(records) {
|
|
|
95
185
|
}
|
|
96
186
|
}
|
|
97
187
|
|
|
98
|
-
|
|
99
|
-
|
|
188
|
+
// RAG-share denominator is ONLY unambiguous code search (RAG + Grep tool);
|
|
189
|
+
// shell plumbing is excluded. null (not 0) when there was no code search at
|
|
190
|
+
// all — "n/a", not "✗ weak", so an all-plumbing log stops reading as failure.
|
|
191
|
+
const codeSearches = search.rag + search.grepTool;
|
|
192
|
+
const ragShare = codeSearches > 0 ? search.rag / codeSearches : null;
|
|
100
193
|
|
|
101
194
|
// Re-read churn proxy: files opened more than once, and the extra opens beyond
|
|
102
195
|
// the first. High churn hints the model is re-fetching context it lost — the
|
|
@@ -110,8 +203,17 @@ export function aggregate(records) {
|
|
|
110
203
|
}
|
|
111
204
|
}
|
|
112
205
|
|
|
206
|
+
// Both sides of this ratio come from the same population — only records
|
|
207
|
+
// whose duplicate status is actually assessable (see the function's note).
|
|
208
|
+
// Using records.length as the denominator instead would let a Bash-heavy
|
|
209
|
+
// log dilute a genuine doubling below the floor.
|
|
210
|
+
const { pairs: dedupPairs, assessable: dedupAssessable } = countDuplicateRegistrationPairs(records);
|
|
211
|
+
const dedupShare = dedupAssessable > 0 ? dedupPairs / dedupAssessable : 0;
|
|
212
|
+
|
|
113
213
|
return {
|
|
114
214
|
total: records.length,
|
|
215
|
+
sessions: sessionSet.size,
|
|
216
|
+
cwds: cwdSet.size,
|
|
115
217
|
search,
|
|
116
218
|
ragShare,
|
|
117
219
|
skills: [...skills.entries()].sort((a, b) => b[1] - a[1]),
|
|
@@ -123,6 +225,24 @@ export function aggregate(records) {
|
|
|
123
225
|
reReadFiles,
|
|
124
226
|
redundantReads,
|
|
125
227
|
reReadRate: reads > 0 ? redundantReads / reads : null,
|
|
228
|
+
// TASK-072: near-duplicate records within DUPLICATE_LOG_WINDOW_MS,
|
|
229
|
+
// consistent with a doubled hook registration. dedupSuspected gates the
|
|
230
|
+
// churn VERDICT line in formatReport — the raw counts above stay as-is
|
|
231
|
+
// (deduplicating them would be a guess, out of scope). Gated on a SHARE,
|
|
232
|
+
// not a single pair: doubling affects every event, so a genuine doubled
|
|
233
|
+
// log is always well past the floor, while a couple of coincidentally
|
|
234
|
+
// fast events must not silence an otherwise readable verdict.
|
|
235
|
+
dedupPairs,
|
|
236
|
+
dedupShare,
|
|
237
|
+
dedupSuspected: dedupShare >= MIN_DUPLICATE_SHARE,
|
|
238
|
+
// Doctrine applied as *method* — narrow reads, contracts-first edits,
|
|
239
|
+
// delegation — leaves no `Skill` tool call, so the skills verdict below
|
|
240
|
+
// can't see it (same blind spot the report already admits for caveman).
|
|
241
|
+
// Edits per read is a partial, honest echo of that method: editing without
|
|
242
|
+
// re-reading first is what narrow-read discipline looks like from the
|
|
243
|
+
// outside. Not a verdict — a read-heavy exploration session scores low here
|
|
244
|
+
// for reasons unrelated to doctrine — so it is shown, never graded (TASK-071).
|
|
245
|
+
editReadRatio: reads > 0 ? edits / reads : null,
|
|
126
246
|
};
|
|
127
247
|
}
|
|
128
248
|
|
|
@@ -130,21 +250,27 @@ function pct(x) {
|
|
|
130
250
|
return x === null ? 'n/a' : `${Math.round(x * 100)}%`;
|
|
131
251
|
}
|
|
132
252
|
|
|
253
|
+
function ratio(x) {
|
|
254
|
+
return x === null ? 'n/a' : `${x.toFixed(1)}:1`;
|
|
255
|
+
}
|
|
256
|
+
|
|
133
257
|
export function formatReport(a) {
|
|
134
258
|
const L = [];
|
|
135
259
|
L.push('tune-context Adoption Report');
|
|
136
260
|
L.push('============================');
|
|
137
261
|
L.push('');
|
|
138
262
|
L.push(`Tool calls logged: ${a.total}`);
|
|
263
|
+
L.push(`Sessions: ${a.sessions} Projects (distinct cwd): ${a.cwds}`);
|
|
139
264
|
L.push('');
|
|
140
265
|
|
|
141
|
-
L.push('Search —
|
|
142
|
-
L.push(` semantic (RAG):
|
|
143
|
-
L.push(`
|
|
144
|
-
L.push(`
|
|
266
|
+
L.push('Search — code search vs shell plumbing:');
|
|
267
|
+
L.push(` semantic (RAG): ${a.search.rag}`);
|
|
268
|
+
L.push(` code search (Grep): ${a.search.grepTool}`);
|
|
269
|
+
L.push(` shell text-search: ${a.search.shell} (grep/find in Bash — operational, not in RAG-share)`);
|
|
270
|
+
L.push(` glob (file find): ${a.search.glob}`);
|
|
145
271
|
L.push(
|
|
146
|
-
` RAG share of
|
|
147
|
-
(a.ragShare === null ? ' (no
|
|
272
|
+
` RAG share of code search: ${pct(a.ragShare)}` +
|
|
273
|
+
(a.ragShare === null ? ' (no code search — RAG or Grep — observed)' : ''),
|
|
148
274
|
);
|
|
149
275
|
L.push('');
|
|
150
276
|
|
|
@@ -153,7 +279,10 @@ export function formatReport(a) {
|
|
|
153
279
|
const mark = DOCTRINE_SKILLS.has(name) ? '★' : ' ';
|
|
154
280
|
L.push(` ${mark} ${String(n).padStart(3)}× ${name}`);
|
|
155
281
|
}
|
|
156
|
-
|
|
282
|
+
// No forward reference to "the verdict below" here: on a totally empty log
|
|
283
|
+
// (total === 0) the verdict section only prints "No data yet" and never
|
|
284
|
+
// mentions skills at all — a "see verdict below" promise would dangle.
|
|
285
|
+
if (a.skills.length === 0) L.push(' (none via the Skill tool)');
|
|
157
286
|
L.push('');
|
|
158
287
|
|
|
159
288
|
L.push(`Subagents spawned: ${a.subagents.length} distinct`);
|
|
@@ -169,6 +298,10 @@ export function formatReport(a) {
|
|
|
169
298
|
` re-read files: ${a.reReadFiles} redundant opens: ${a.redundantReads}` +
|
|
170
299
|
` (${pct(a.reReadRate)} of reads)`,
|
|
171
300
|
);
|
|
301
|
+
L.push(
|
|
302
|
+
` edit:read ratio: ${ratio(a.editReadRatio)} — edits per read; narrow,` +
|
|
303
|
+
' contracts-first edits (doctrine-as-method, not tool-call-visible) push this up.',
|
|
304
|
+
);
|
|
172
305
|
L.push('');
|
|
173
306
|
|
|
174
307
|
// Verdict — behavioral, not a cost claim. Frozen thresholds so the model
|
|
@@ -177,23 +310,68 @@ export function formatReport(a) {
|
|
|
177
310
|
if (a.total === 0) {
|
|
178
311
|
L.push(' No data yet. Run some sessions with the hook installed, then re-run.');
|
|
179
312
|
} else {
|
|
180
|
-
|
|
313
|
+
// RAG + doctrine skills are cross-session tendencies: gate them on sample
|
|
314
|
+
// size so a single atypical session can't read as a trend. Below the
|
|
315
|
+
// PO-frozen floor, withhold both judgments (TASK-070).
|
|
316
|
+
if (a.sessions < MIN_SESSIONS_FOR_VERDICT) {
|
|
181
317
|
L.push(
|
|
182
|
-
a.
|
|
183
|
-
|
|
184
|
-
|
|
318
|
+
` ~ RAG/skills verdict withheld — needs ≥${MIN_SESSIONS_FOR_VERDICT} distinct sessions, have ${a.sessions}.` +
|
|
319
|
+
' One session is a sample, not a trend; collect more, then re-run.',
|
|
320
|
+
);
|
|
321
|
+
} else {
|
|
322
|
+
if (a.search.rag === 0) {
|
|
323
|
+
// Availability (Option 1, PO 2026-07-24): search_codebase never observed
|
|
324
|
+
// → report as unavailable, never ✗ and never ✓. The behavioral log can't
|
|
325
|
+
// tell "RAG not installed here" from "installed but never reached for",
|
|
326
|
+
// so it must not accuse — a known, accepted blind spot (TASK-070 notes).
|
|
327
|
+
L.push(
|
|
328
|
+
' · RAG not observed — no search_codebase in these sessions. Not counted' +
|
|
329
|
+
' as ✗: can\'t tell "not installed" from "installed but unused".',
|
|
330
|
+
);
|
|
331
|
+
} else {
|
|
332
|
+
// rag > 0 ⇒ denominator (rag + grepTool) > 0 ⇒ ragShare is a number.
|
|
333
|
+
L.push(
|
|
334
|
+
a.ragShare >= 0.5
|
|
335
|
+
? ' ✓ RAG lever working — semantic search is the default over grep.'
|
|
336
|
+
: ' ✗ RAG lever weak — grep still dominates code search. Revisit index/skill wiring.',
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
// Zero `Skill` calls is NOT proof doctrine is unused — narrow reads,
|
|
340
|
+
// contracts-first edits, and delegation are the method working *without*
|
|
341
|
+
// a tool call. A hard ✗ here would penalize exactly the discipline this
|
|
342
|
+
// project trains toward, so it is replaced with a neutral note pointing
|
|
343
|
+
// at edit:read (see the churn section above) — a partial, honest proxy,
|
|
344
|
+
// not a substitute verdict (TASK-071).
|
|
345
|
+
L.push(
|
|
346
|
+
a.skills.length > 0
|
|
347
|
+
? ' ✓ Doctrine skills are being invoked.'
|
|
348
|
+
: ' · No explicit Skill invocations — doctrine-as-method (narrow reads,' +
|
|
349
|
+
' contracts-first, delegation) isn\'t tool-call-visible. See edit:read above.',
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
// Churn is a within-session symptom — valid at any sample size, so NOT gated
|
|
353
|
+
// on MIN_SESSIONS_FOR_VERDICT. But zero reads is zero churn signal: report
|
|
354
|
+
// n/a, never "elevated" — that would be a false ✗ over no data (same
|
|
355
|
+
// "say only what the sample supports" rule as the verdict above).
|
|
356
|
+
if (a.dedupSuspected) {
|
|
357
|
+
// TASK-072: withhold the verdict rather than print a fabricated ✓/~ —
|
|
358
|
+
// a doubled hook registration inflates re-read churn without any real
|
|
359
|
+
// re-reading happening. "Only say what the data supports" (TASK-070).
|
|
360
|
+
L.push(
|
|
361
|
+
` ~ re-read churn: undecidable — ${a.dedupPairs} near-duplicate event pair(s),` +
|
|
362
|
+
` ${pct(a.dedupShare)} of comparable records, consistent with the hook being` +
|
|
363
|
+
' registered twice (plugin + settings.json). Every event logs twice, so churn' +
|
|
364
|
+
' can\'t be read off this log; see README ("Measuring adoption").',
|
|
365
|
+
);
|
|
366
|
+
} else if (a.reReadRate === null) {
|
|
367
|
+
L.push(' · re-read churn: n/a — no reads logged yet.');
|
|
368
|
+
} else {
|
|
369
|
+
L.push(
|
|
370
|
+
a.reReadRate <= 0.25
|
|
371
|
+
? ' ✓ Low re-read churn.'
|
|
372
|
+
: ' ~ Elevated re-read churn — context may be getting lost (compaction/RAG lever).',
|
|
185
373
|
);
|
|
186
374
|
}
|
|
187
|
-
L.push(
|
|
188
|
-
a.skills.length > 0
|
|
189
|
-
? ' ✓ Doctrine skills are being invoked.'
|
|
190
|
-
: ' ✗ Doctrine skills unused — check skill descriptions / discoverability.',
|
|
191
|
-
);
|
|
192
|
-
L.push(
|
|
193
|
-
a.reReadRate !== null && a.reReadRate <= 0.25
|
|
194
|
-
? ' ✓ Low re-read churn.'
|
|
195
|
-
: ' ~ Elevated re-read churn — context may be getting lost (compaction/RAG lever).',
|
|
196
|
-
);
|
|
197
375
|
}
|
|
198
376
|
L.push('');
|
|
199
377
|
L.push('Cost (dollars) is not measurable from hooks. For hard cost, enable OTEL:');
|
package/hooks/adoption-log.mjs
CHANGED
|
@@ -28,8 +28,8 @@ import {
|
|
|
28
28
|
renameSync,
|
|
29
29
|
statSync,
|
|
30
30
|
} from 'node:fs';
|
|
31
|
-
import { homedir } from 'node:os';
|
|
32
31
|
import { dirname, join } from 'node:path';
|
|
32
|
+
import { claudeBaseDir } from './config-dir.mjs';
|
|
33
33
|
|
|
34
34
|
const MAX_LOG_BYTES = 5 * 1024 * 1024; // rotate to <path>.1 past this (bounded ~2×)
|
|
35
35
|
|
|
@@ -42,7 +42,7 @@ function readStdin() {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
export function logFilePath() {
|
|
45
|
-
return join(
|
|
45
|
+
return join(claudeBaseDir(), 'tune-context', 'adoption.jsonl');
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
// Detect a text-search shell command (grep/rg/find/ag/ack), the grep-side of the
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Shared base-dir resolution for hook STATE paths (snapshots, adoption log).
|
|
2
|
+
// Mirrors claudeDirs() in cli/detect.mjs — same rule, but must live here rather
|
|
3
|
+
// than be imported from cli/: syncDoctrine only copies hooks/ into
|
|
4
|
+
// <claudeDir>/hooks/, so an installed hook has no access to cli/ at runtime.
|
|
5
|
+
// eval/adoption-report.mjs imports this too, via a relative path (eval/ is
|
|
6
|
+
// never installed, so that path is safe only in-repo).
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
export function claudeBaseDir() {
|
|
11
|
+
return process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
|
|
12
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// UserPromptSubmit hook: measures how much context this session is actually
|
|
3
|
+
// carrying and, once it crosses a threshold, injects a short reminder that it
|
|
4
|
+
// may be time to shed some. See tasks/TASK-079.
|
|
5
|
+
//
|
|
6
|
+
// Why a hook has to do this at all: the model cannot see its own context
|
|
7
|
+
// usage — nothing in a turn tells it how many tokens it is re-sending. The
|
|
8
|
+
// user can see a number but has no reason to watch it. Claude Code exposes no
|
|
9
|
+
// timer and no context-threshold event (verified against the hooks docs), so
|
|
10
|
+
// the only place the measurement can come from is the transcript.
|
|
11
|
+
//
|
|
12
|
+
// Honest scope, and the reason this reminds on SIZE rather than on "phase
|
|
13
|
+
// boundary": a hook is a plain script with no model access, so it cannot know
|
|
14
|
+
// that a work thread just ended. Size is measurable; a phase boundary is a
|
|
15
|
+
// judgment. This hook therefore supplies the measurement the model can't see,
|
|
16
|
+
// and leaves the timing judgment to the model — the reminder explicitly tells
|
|
17
|
+
// it to stay quiet mid-task. The phase-boundary half is the `phase-workflow`
|
|
18
|
+
// skill's job, model-side, where judgment actually lives.
|
|
19
|
+
//
|
|
20
|
+
// The number is real, not a proxy: Claude Code records per-request `usage` in
|
|
21
|
+
// the transcript, and input_tokens + cache_read_input_tokens +
|
|
22
|
+
// cache_creation_input_tokens is exactly the context that was re-sent.
|
|
23
|
+
import { closeSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { dirname, join } from 'node:path';
|
|
25
|
+
import { claudeBaseDir } from './config-dir.mjs';
|
|
26
|
+
|
|
27
|
+
// PO decision 2026-07-25 (TASK-079), frozen per the TASK-062 anti-bias rule:
|
|
28
|
+
// a threshold that judges the session's own weight is the PO's call, not the
|
|
29
|
+
// model's. Chosen from a measured 12.6h session (peak 547k tokens, 3
|
|
30
|
+
// compactions): with fire-once-per-crossing, 80k/100k/120k/150k all produce
|
|
31
|
+
// the SAME 4 reminders, so the choice is about how early the warning lands,
|
|
32
|
+
// not about noise. 120k is ~60% of a standard 200k window and sits clearly
|
|
33
|
+
// below the ~165k where that session's user compacted by hand.
|
|
34
|
+
//
|
|
35
|
+
// Override for a larger window (e.g. Opus 1M) — the transcript does NOT carry
|
|
36
|
+
// the context-window size (checked: no such field), so this cannot be
|
|
37
|
+
// auto-scaled and an absolute number is the honest interface.
|
|
38
|
+
const DEFAULT_THRESHOLD_TOKENS = 120_000;
|
|
39
|
+
|
|
40
|
+
// A drop to below this fraction of the previous reading means the context was
|
|
41
|
+
// compacted or cleared, so the reminder re-arms. Deliberately loose: normal
|
|
42
|
+
// turn-to-turn variation is a few percent, while a compaction cuts context by
|
|
43
|
+
// 60-95% (measured: 166k->58k, 541k->23k, 165k->48k).
|
|
44
|
+
const REARM_DROP_RATIO = 0.6;
|
|
45
|
+
|
|
46
|
+
// Read only the tail: transcripts reach tens of MB (the session this was
|
|
47
|
+
// built from was 9.4MB after 12.6h) and this runs on EVERY prompt, so parsing
|
|
48
|
+
// the whole file would make the tool's own overhead grow with session length —
|
|
49
|
+
// precisely the cost curve it exists to fight. The last entries are all we
|
|
50
|
+
// need, and 256KB comfortably spans several of them.
|
|
51
|
+
const TAIL_BYTES = 256 * 1024;
|
|
52
|
+
|
|
53
|
+
export function thresholdTokens(env = process.env) {
|
|
54
|
+
const raw = env.TUNE_CONTEXT_REMIND_TOKENS;
|
|
55
|
+
if (raw === undefined) return DEFAULT_THRESHOLD_TOKENS;
|
|
56
|
+
const parsed = Number(raw);
|
|
57
|
+
// A malformed override must not silently disable the reminder, nor crash a
|
|
58
|
+
// prompt — fall back to the frozen default.
|
|
59
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_THRESHOLD_TOKENS;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readStdin() {
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(readFileSync(0, 'utf8'));
|
|
65
|
+
} catch {
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Last TAIL_BYTES of a file as text, without reading what comes before it. */
|
|
71
|
+
function readTail(path, bytes = TAIL_BYTES) {
|
|
72
|
+
const size = statSync(path).size;
|
|
73
|
+
const start = Math.max(0, size - bytes);
|
|
74
|
+
const length = size - start;
|
|
75
|
+
if (length === 0) return '';
|
|
76
|
+
const buf = Buffer.allocUnsafe(length);
|
|
77
|
+
const fd = openSync(path, 'r');
|
|
78
|
+
try {
|
|
79
|
+
readSync(fd, buf, 0, length, start);
|
|
80
|
+
} finally {
|
|
81
|
+
closeSync(fd);
|
|
82
|
+
}
|
|
83
|
+
return buf.toString('utf8');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Context size (tokens) of the most recent request recorded in the transcript,
|
|
88
|
+
* or null when the transcript has no usable entry yet.
|
|
89
|
+
*
|
|
90
|
+
* Scans backwards so the newest reading wins, and tolerates a partial first
|
|
91
|
+
* line: a tail read almost always starts mid-line, and that fragment simply
|
|
92
|
+
* fails JSON.parse and is skipped.
|
|
93
|
+
*/
|
|
94
|
+
export function latestContextTokens(transcriptText) {
|
|
95
|
+
const lines = transcriptText.split('\n');
|
|
96
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
97
|
+
const line = lines[i].trim();
|
|
98
|
+
if (!line) continue;
|
|
99
|
+
let entry;
|
|
100
|
+
try {
|
|
101
|
+
entry = JSON.parse(line);
|
|
102
|
+
} catch {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const usage = entry?.message?.usage;
|
|
106
|
+
if (!usage) continue;
|
|
107
|
+
const total =
|
|
108
|
+
(usage.input_tokens || 0) + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0);
|
|
109
|
+
// Guard against the tiny synthetic entries Claude Code also writes.
|
|
110
|
+
if (total > 0) return total;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function stateFile() {
|
|
116
|
+
return join(claudeBaseDir(), 'tune-context', 'context-reminder.json');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function readState(path) {
|
|
120
|
+
try {
|
|
121
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
122
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
123
|
+
} catch {
|
|
124
|
+
return {};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// One entry per session id; sessions go stale the moment they end, so old
|
|
129
|
+
// entries are dropped on write rather than accumulating forever (the same
|
|
130
|
+
// hygiene TASK-076 gave state/).
|
|
131
|
+
const STATE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
132
|
+
|
|
133
|
+
function pruneState(state, now) {
|
|
134
|
+
for (const [key, value] of Object.entries(state)) {
|
|
135
|
+
if (!value || typeof value.ts !== 'number' || now - value.ts > STATE_MAX_AGE_MS) delete state[key];
|
|
136
|
+
}
|
|
137
|
+
return state;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Pure decision: given the previous reading for this session and the current
|
|
142
|
+
* one, should a reminder fire, and what is the session's next state?
|
|
143
|
+
*
|
|
144
|
+
* Fires ONCE per crossing, not on every prompt above the threshold. A reminder
|
|
145
|
+
* costs tokens on the very prompt it appears in, so a context-efficiency tool
|
|
146
|
+
* that nagged every turn would be spending the thing it claims to save.
|
|
147
|
+
*/
|
|
148
|
+
export function decide(prev, total, threshold) {
|
|
149
|
+
let fired = prev?.fired === true;
|
|
150
|
+
// A large drop means the context was compacted/cleared: arm again so the
|
|
151
|
+
// NEXT time it grows past the threshold the user hears about it.
|
|
152
|
+
if (prev && typeof prev.last === 'number' && total < prev.last * REARM_DROP_RATIO) fired = false;
|
|
153
|
+
|
|
154
|
+
const remind = !fired && total >= threshold;
|
|
155
|
+
return { remind, next: { last: total, fired: fired || remind, ts: Date.now() } };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function reminderText(total) {
|
|
159
|
+
const k = Math.round(total / 1000);
|
|
160
|
+
return [
|
|
161
|
+
`[tune-context] This session is now carrying ~${k}k tokens of context, and every further turn re-sends all of it.`,
|
|
162
|
+
'If the current work thread is finished: suggest /compact (or /clear when switching to unrelated work).',
|
|
163
|
+
'If you are mid-task: say nothing now and raise it when the thread closes.',
|
|
164
|
+
'The PreCompact hook preserves branch, dirty files and recent commits across a compaction — judgment calls and open threads are NOT preserved, so write those down first.',
|
|
165
|
+
].join(' ');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Never fails a prompt: this runs on EVERY UserPromptSubmit, so a broken or
|
|
169
|
+
// unreadable transcript, an unwritable state dir or a malformed payload must
|
|
170
|
+
// cost the user nothing. Same contract as adoption-log.mjs and (since
|
|
171
|
+
// TASK-076) pre-compact-snapshot.mjs. Silence is the correct failure mode —
|
|
172
|
+
// emitting nothing simply means no reminder this turn.
|
|
173
|
+
function main() {
|
|
174
|
+
try {
|
|
175
|
+
const input = readStdin();
|
|
176
|
+
const transcriptPath = input.transcript_path;
|
|
177
|
+
if (!transcriptPath) return;
|
|
178
|
+
|
|
179
|
+
const total = latestContextTokens(readTail(transcriptPath));
|
|
180
|
+
if (total === null) return;
|
|
181
|
+
|
|
182
|
+
const session = input.session_id || 'unknown';
|
|
183
|
+
const path = stateFile();
|
|
184
|
+
const state = readState(path);
|
|
185
|
+
const { remind, next } = decide(state[session], total, thresholdTokens());
|
|
186
|
+
|
|
187
|
+
state[session] = next;
|
|
188
|
+
try {
|
|
189
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
190
|
+
writeFileSync(path, JSON.stringify(pruneState(state, Date.now())));
|
|
191
|
+
} catch {
|
|
192
|
+
// Unwritable state only costs us the once-per-crossing memory; the
|
|
193
|
+
// reminder itself is still correct for this turn.
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!remind) return;
|
|
197
|
+
process.stdout.write(
|
|
198
|
+
JSON.stringify({
|
|
199
|
+
hookSpecificOutput: {
|
|
200
|
+
hookEventName: 'UserPromptSubmit',
|
|
201
|
+
additionalContext: reminderText(total),
|
|
202
|
+
},
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
} catch {
|
|
206
|
+
// non-fatal by design — never block a prompt over a reminder
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (process.argv[1] && process.argv[1].endsWith('context-reminder.mjs')) {
|
|
211
|
+
main();
|
|
212
|
+
}
|
package/hooks/hooks.json
CHANGED