@bglocation/tune-context 1.1.0 → 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/cli/generate.mjs CHANGED
@@ -76,6 +76,12 @@ function templateInnerBlock(templatePath) {
76
76
  const raw = readFileSync(templatePath, 'utf8');
77
77
  const start = raw.indexOf('<!-- >>> tune-context (managed) >>>');
78
78
  const end = raw.indexOf('<!-- <<< tune-context (managed) <<< -->');
79
+ // A missing marker makes `start`/`end` -1; indexOf('\n', -1) silently treats
80
+ // -1 as 0, which used to return garbage sliced from the top of the file
81
+ // instead of failing. A broken template must not become a broken CLAUDE.md.
82
+ if (start === -1 || end === -1 || end < start) {
83
+ throw new Error(`template ${templatePath} is missing the tune-context managed-block markers`);
84
+ }
79
85
  const startLineEnd = raw.indexOf('\n', start) + 1;
80
86
  return raw.slice(startLineEnd, end).trimEnd();
81
87
  }
@@ -1,6 +1,15 @@
1
1
  // Mirrors this package's skills/agents/hooks into a Claude Code config dir
2
- // (~/.claude by default). Idempotent removes each destination entry before
3
- // copying, so deletions at the source propagate too, not just an overlay.
2
+ // (~/.claude by default). Idempotent for content that still exists at the
3
+ // source: each entry is removed then recopied, so a changed file/directory
4
+ // shape can't leave old siblings behind inside THAT entry. This is an
5
+ // OVERLAY, not a mirror, across runs: the outer loop is `readdirSync(from)`,
6
+ // so an entry no longer present at the source is simply never visited again —
7
+ // it is NOT deleted from the destination (review finding, TASK-077; verified
8
+ // empirically, not assumed. See the "KNOWN GAP" test in
9
+ // test/cli-sync-doctrine.test.mjs). A prune pass would need to diff against
10
+ // what THIS tool previously wrote, since claudeDir may also hold a user's own
11
+ // unrelated skills/agents/hooks that must never be touched — deliberately not
12
+ // attempted here without that design decision.
4
13
  import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
5
14
  import { dirname, join } from 'node:path';
6
15
 
package/cli/verify.mjs CHANGED
@@ -24,15 +24,31 @@ function extractHookPath(command) {
24
24
  return m ? (m[1] ?? m[2]) : null;
25
25
  }
26
26
 
27
- // Expand $HOME / ${HOME} the way the hook will at run time. Must use the exact
28
- // base claudeDirs() uses (process.env.HOME || homedir()) a different notion of
29
- // "home" on the two sides of the comparison would itself manufacture a mismatch
30
- // and fire a false alarm on a correct install.
27
+ // Expand $HOME / ${HOME} inside a hook COMMAND STRING the way it resolves at
28
+ // run time (process.env.HOME, falling back to homedir() if HOME is somehow
29
+ // unset) the literal shell variable baked into the *default*, non-override
30
+ // hook command form (hooksDirForTemplate() in cli/generate.mjs). UNRELATED to
31
+ // CLAUDE_CONFIG_DIR: that rule lives solely in claudeBaseDir()
32
+ // (hooks/config-dir.mjs, TASK-074) and governs which directory claudeDir
33
+ // itself resolves to, a different axis from $HOME shell-expansion. A
34
+ // different notion of "home" on the two sides of the comparison below would
35
+ // itself manufacture a mismatch and fire a false alarm on a correct install.
31
36
  function expandHome(p) {
32
37
  const home = process.env.HOME || homedir();
33
38
  return p.replace(/\$\{HOME\}/g, home).replace(/\$HOME/g, home);
34
39
  }
35
40
 
41
+ // TASK-072: signature of the PLUGIN channel's own hook registration turning up
42
+ // where a settings.json entry is being checked. `${CLAUDE_PLUGIN_ROOT}` is
43
+ // Claude Code's substitution for a plugin's OWN hooks.json — it is never
44
+ // expanded for a command sitting in settings.json, so resolving/existsSync-ing
45
+ // it here would always read as "missing", which would be a lie: the plugin
46
+ // channel may be working perfectly well from Claude Code's side. Commands
47
+ // matching this are pulled out of the resolvability check below and reported
48
+ // separately, always informational — we can tell the command SHAPE is
49
+ // present, never whether the plugin is actually enabled (Option B, TASK-072).
50
+ const PLUGIN_PATH_HINT = /\$\{CLAUDE_PLUGIN_ROOT\}|[\\/]plugins[\\/]/;
51
+
36
52
  /**
37
53
  * @returns {{ok: boolean, checks: {name: string, ok: boolean, detail?: string}[]}}
38
54
  */
@@ -48,9 +64,38 @@ export function verify({ claudeDir, userSettingsPath, expectedHookCommands, proj
48
64
  const settings = readJson(userSettingsPath);
49
65
  for (const { event, scriptName } of expectedHookCommands) {
50
66
  const entries = settings?.hooks?.[event] || [];
51
- const commands = entries.flatMap((g) => (g.hooks || []).map((h) => h.command)).filter(Boolean);
67
+ const allCommands = entries.flatMap((g) => (g.hooks || []).map((h) => h.command)).filter(Boolean);
52
68
  const installedPath = resolve(join(claudeDir, 'hooks', scriptName));
53
69
 
70
+ // Classify by DESTINATION, not by spelling: a command that resolves to
71
+ // the very file we installed is the CLI registration, whatever its path
72
+ // happens to look like. Without this, PLUGIN_PATH_HINT's loose
73
+ // `/plugins/` arm swallowed a correct install whose config dir merely
74
+ // sits under such a path (e.g. CLAUDE_CONFIG_DIR=/opt/plugins/claude) —
75
+ // it was dropped from `commands`, then reported "not found in
76
+ // settings.json", i.e. a false RED on a perfect install, plus a plugin
77
+ // note for a plugin that isn't there (review finding, EPIC-008).
78
+ // `${CLAUDE_PLUGIN_ROOT}` stays correctly classified: it is literal text
79
+ // in settings.json (Claude Code only expands it for a plugin's own
80
+ // hooks.json), so it can never resolve to the installed file.
81
+ const isInstalledTarget = (c) => {
82
+ const p = extractHookPath(c);
83
+ return p !== null && resolve(expandHome(p)) === installedPath;
84
+ };
85
+ const isPluginShaped = (c) => PLUGIN_PATH_HINT.test(c) && !isInstalledTarget(c);
86
+
87
+ // Report the plugin-channel shape separately and up front — see
88
+ // PLUGIN_PATH_HINT above — then exclude it from the resolvability check
89
+ // below so it can never be misread as a dead/stale leftover.
90
+ for (const pluginCmd of allCommands.filter((c) => c.includes(scriptName) && isPluginShaped(c))) {
91
+ checks.push({
92
+ name: `hooks.${event}: ${scriptName} also has a plugin-channel registration`,
93
+ ok: true,
94
+ detail: `${pluginCmd} — if the tune-context plugin is also enabled, every ${event} event logs twice; see README ("Measuring adoption") for the re-read-churn effect this has on adoption-report`,
95
+ });
96
+ }
97
+ const commands = allCommands.filter((c) => !isPluginShaped(c));
98
+
54
99
  // Match by the basename of each command's target path — ALL of them, not
55
100
  // just the first. mergeHooks is a set keyed by command, so a path change
56
101
  // (e.g. adopting CLAUDE_CONFIG_DIR) appends a new entry next to the old
@@ -30,6 +30,29 @@ const DOCTRINE_SKILLS = new Set([
30
30
  // Churn is exempt — a within-session symptom, readable at any n.
31
31
  const MIN_SESSIONS_FOR_VERDICT = 3;
32
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
+
33
56
  function defaultLogPath() {
34
57
  return join(claudeBaseDir(), 'tune-context', 'adoption.jsonl');
35
58
  }
@@ -64,6 +87,46 @@ export function parseLog(text) {
64
87
  return out;
65
88
  }
66
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
+
67
130
  export function aggregate(records) {
68
131
  const search = { rag: 0, grepTool: 0, shell: 0, glob: 0 };
69
132
  const skills = new Map();
@@ -140,6 +203,13 @@ export function aggregate(records) {
140
203
  }
141
204
  }
142
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
+
143
213
  return {
144
214
  total: records.length,
145
215
  sessions: sessionSet.size,
@@ -155,6 +225,16 @@ export function aggregate(records) {
155
225
  reReadFiles,
156
226
  redundantReads,
157
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,
158
238
  // Doctrine applied as *method* — narrow reads, contracts-first edits,
159
239
  // delegation — leaves no `Skill` tool call, so the skills verdict below
160
240
  // can't see it (same blind spot the report already admits for caveman).
@@ -273,7 +353,17 @@ export function formatReport(a) {
273
353
  // on MIN_SESSIONS_FOR_VERDICT. But zero reads is zero churn signal: report
274
354
  // n/a, never "elevated" — that would be a false ✗ over no data (same
275
355
  // "say only what the sample supports" rule as the verdict above).
276
- if (a.reReadRate === null) {
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) {
277
367
  L.push(' · re-read churn: n/a — no reads logged yet.');
278
368
  } else {
279
369
  L.push(
@@ -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
  }
@@ -9,8 +9,14 @@
9
9
  // capture what the filesystem/git already know (branch, dirty files, recent
10
10
  // commits) — not decisions or open threads from the conversation. It is a
11
11
  // safety net against losing that mechanical state, not a summary.
12
+ //
13
+ // Claude Code also has a PostCompact event. Staying on PreCompact +
14
+ // SessionStart(compact) is deliberate, not an oversight (TASK-078): this pair
15
+ // is what TASK-056/061 proved out with a smoke-tested round trip, and nothing
16
+ // since has needed PostCompact's different timing.
12
17
  import { execFileSync } from 'node:child_process';
13
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
18
+ import { createHash } from 'node:crypto';
19
+ import { mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
14
20
  import { join } from 'node:path';
15
21
  import { claudeBaseDir } from './config-dir.mjs';
16
22
 
@@ -34,9 +40,50 @@ function git(cwd, args) {
34
40
  }
35
41
  }
36
42
 
43
+ // Hash suffix makes this injective. The slug alone collapses distinct paths
44
+ // that normalize the same way (/home/a/b and /home/a-b both slug to
45
+ // "home-a-b"), which let two projects share one file — and SessionStart
46
+ // (compact) would reinject one project's git state into an unrelated
47
+ // session's compaction (TASK-076). Slug stays first for human readability;
48
+ // the hash is what actually prevents the collision. Renaming the file shape
49
+ // orphans pre-existing snapshots — acceptable, they're one-compaction-lived —
50
+ // noted in CHANGELOG per the task.
37
51
  export function stateFilePath(cwd) {
38
52
  const safe = cwd.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '');
39
- return join(claudeBaseDir(), 'tune-context', 'state', `${safe}.md`);
53
+ const hash = createHash('sha256').update(cwd).digest('hex').slice(0, 8);
54
+ return join(claudeBaseDir(), 'tune-context', 'state', `${safe}-${hash}.md`);
55
+ }
56
+
57
+ // PO decision 2026-07-25 (TASK-076): age-based, 30 days. One file per distinct
58
+ // cwd (stateFilePath above) means every worktree or temp checkout a user ever
59
+ // compacted in gets a permanent file, not just abandoned projects — and this
60
+ // file is rewritten on every compaction of a still-active project, so mtime
61
+ // age is an exact recency signal, not a proxy.
62
+ const MAX_SNAPSHOT_AGE_MS = 30 * 24 * 60 * 60 * 1000;
63
+
64
+ // Self-contained like git() above: never throws, so a missing/unreadable
65
+ // state/ directory or an unremovable file can't take the write down with it.
66
+ // `keep` is excluded from consideration outright, not just by age, so the
67
+ // snapshot this run just wrote can never be pruned regardless of mtime/clock
68
+ // edge cases (no reliance on "the fresh write's mtime beats the cutoff").
69
+ export function pruneStateDir(dir, keep) {
70
+ let entries;
71
+ try {
72
+ entries = readdirSync(dir, { withFileTypes: true });
73
+ } catch {
74
+ return;
75
+ }
76
+ const cutoff = Date.now() - MAX_SNAPSHOT_AGE_MS;
77
+ for (const entry of entries) {
78
+ if (!entry.isFile()) continue;
79
+ const path = join(dir, entry.name);
80
+ if (path === keep) continue;
81
+ try {
82
+ if (statSync(path).mtimeMs < cutoff) unlinkSync(path);
83
+ } catch {
84
+ // gone already, or unremovable — not this hook's problem
85
+ }
86
+ }
40
87
  }
41
88
 
42
89
  export function buildSnapshot(input) {
@@ -61,11 +108,26 @@ export function buildSnapshot(input) {
61
108
  return lines.join('\n') + '\n';
62
109
  }
63
110
 
111
+ // Never fails the host tool call: this snapshot is a safety net for a
112
+ // compaction, not a critical feature, so its own failure can't be louder than
113
+ // the context loss it guards against. An unwritable state dir (container,
114
+ // read-only volume, misdirected CLAUDE_CONFIG_DIR) must not surface as a
115
+ // nonzero exit at the exact moment the session is under context pressure.
116
+ // Deliberately no console.error — hooks don't write to stdout/stderr
117
+ // (transcript noise) — a silent safety-net failure is cheaper than a loud
118
+ // one. Mirrors hooks/adoption-log.mjs's main(), which this file previously
119
+ // lacked parity with (TASK-076).
64
120
  function main() {
65
- const input = readStdin();
66
- const path = stateFilePath(input.cwd || process.cwd());
67
- mkdirSync(join(path, '..'), { recursive: true });
68
- writeFileSync(path, buildSnapshot(input));
121
+ try {
122
+ const input = readStdin();
123
+ const cwd = input.cwd || process.cwd();
124
+ const path = stateFilePath(cwd);
125
+ mkdirSync(join(path, '..'), { recursive: true });
126
+ writeFileSync(path, buildSnapshot(input));
127
+ pruneStateDir(join(path, '..'), path);
128
+ } catch {
129
+ // non-fatal by design — never block a compaction over a snapshot write
130
+ }
69
131
  }
70
132
 
71
133
  if (process.argv[1] && process.argv[1].endsWith('pre-compact-snapshot.mjs')) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bglocation/tune-context",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "description": "Context-efficiency configurator for Claude Code — token-saving doctrine packaged as skills, plus a tune-context configurator that scaffolds a lean CLAUDE.md, doctrine skills, subagents, settings, hooks and MCP wiring.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,7 +45,9 @@ comments in the domain vocabulary someone would *search* with — better contrac
45
45
  ## 6. Measure, don't believe
46
46
  If the tool logs usage (`.rag/usage.jsonl` → `rag-usage`), review it
47
47
  periodically: is semantic search actually replacing grep? Promote or drop levers
48
- on data, not faith.
48
+ on data, not faith. If `tune-context` is set up, `node eval/adoption-report.mjs`
49
+ covers the rest of this doctrine the same way — search lever, skills, subagents,
50
+ re-read churn — as a behavioral proxy (tool calls), not token cost.
49
51
 
50
52
  ## In a new project
51
53
  Set up semantic search proactively — `npx -p @bglocation/code-search-mcp