@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.
@@ -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(homedir(), '.claude', 'tune-context', 'adoption.jsonl');
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: ~/.claude/tune-context/adoption.jsonl)
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, text: 0, glob: 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(); // detail(file) -> times read
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
- if (r.detail === 'rag') search.rag++;
74
- else if (r.detail === 'glob') search.glob++;
75
- else search.text++;
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
- if (r.detail) bump(readCounts, r.detail);
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
- const contentSearches = search.rag + search.text;
99
- const ragShare = contentSearches > 0 ? search.rag / contentSearches : null;
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 — RAG vs grep:');
142
- L.push(` semantic (RAG): ${a.search.rag}`);
143
- L.push(` text (grep/find):${String(a.search.text).padStart(2)}`);
144
- L.push(` glob (file find):${String(a.search.glob).padStart(2)}`);
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 content search: ${pct(a.ragShare)}` +
147
- (a.ragShare === null ? ' (no content searches yet)' : ''),
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
- if (a.skills.length === 0) L.push(' (none doctrine skills unused)');
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
- if (a.ragShare !== null) {
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.ragShare >= 0.5
183
- ? ' RAG lever working semantic search is the default over grep.'
184
- : ' ✗ RAG lever weak — grep still dominates content search. Revisit index/skill wiring.',
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:');
@@ -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(homedir(), '.claude', 'tune-context', 'adoption.jsonl');
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
@@ -43,6 +43,17 @@
43
43
  }
44
44
  ]
45
45
  }
46
+ ],
47
+ "UserPromptSubmit": [
48
+ {
49
+ "matcher": "*",
50
+ "hooks": [
51
+ {
52
+ "type": "command",
53
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/context-reminder.mjs\""
54
+ }
55
+ ]
56
+ }
46
57
  ]
47
58
  }
48
59
  }