@bglocation/tune-context 1.1.0 → 2.0.1

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.
@@ -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,7 +1,7 @@
1
1
  {
2
2
  "name": "@bglocation/tune-context",
3
- "version": "1.1.0",
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.",
3
+ "version": "2.0.1",
4
+ "description": "Context-efficiency configurator for Claude Code \u2014 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",
7
7
  "author": "Szymon Walczak",
@@ -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
@@ -43,10 +43,32 @@ not guesses); monorepo vs single project (workspaces, `packages/`, `apps/`).
43
43
  | project | `./.mcp.json` |
44
44
  | user | `~/.claude.json` → top-level `mcpServers` |
45
45
  | local | `~/.claude.json` → `["<abs project path>"].mcpServers` |
46
- | gating | `settings.json`: `enabledMcpjsonServers`, `disabledMcpjsonServers`, `allowedMcpServers`, `deniedMcpServers` |
46
+ | rejected | any `settings.json`: `disabledMcpjsonServers` (project `.mcp.json` servers only) |
47
47
 
48
48
  If `CLAUDE_CONFIG_DIR` is set, `.claude.json` lives there instead of `~`.
49
49
 
50
+ **A rejected server is not a lever.** A project `.mcp.json` server named in
51
+ `disabledMcpjsonServers` (in *any* settings file) is rejected — `claude mcp get`
52
+ shows it as `✘ Rejected`. Skip it: no `mcp__<server>__*` permission rule, no
53
+ CLAUDE.md line promising a capability this session does not have. Say which
54
+ servers you skipped and why.
55
+
56
+ Read the neighbouring keys carefully rather than by name — the three below look
57
+ like the same mechanism and are not:
58
+ - `enabledMcpjsonServers` — an *approval* list. A `.mcp.json` server that is
59
+ absent from it is **⏸ pending approval**, not disabled; the user is prompted
60
+ and may approve it. Do not treat absence as rejection.
61
+ - `allowedMcpServers` / `deniedMcpServers` — arrays of **objects**
62
+ (`{serverName}` | `{serverUrl}` | `{serverCommand}`). They may sit in any
63
+ settings file and merge from every source, but they are organization policy,
64
+ not a per-project toggle: enforcement rests on a managed tier
65
+ (`managed-settings.json`, server-managed settings, MDM/registry), and matching
66
+ uses URL wildcards, `${VAR}` expansion and denylist-over-allowlist precedence.
67
+ The docs warn a `serverName` entry "is not a security control".
68
+ - `disabledMcpServers` / `enabledMcpServers` (note: no `json`) — a *different*
69
+ pair, living per-project in `~/.claude.json`, covering user-configured,
70
+ plugin and claude.ai servers. Disjoint from the `…Mcpjson…` keys above.
71
+
50
72
  **Classify each server** — this is what turns "a server exists" into a rule:
51
73
 
52
74
  | Type | Rule to write into the project CLAUDE.md |
@@ -65,8 +87,9 @@ If `CLAUDE_CONFIG_DIR` is set, `.claude.json` lives there instead of `~`.
65
87
  **Existing config.** Note whether `./CLAUDE.md`, `.claude/settings.json`,
66
88
  `.mcp.json`, `rag.config.json` already exist — you will merge, never clobber.
67
89
  For `settings.json` also check `hooks.PreCompact` / `hooks.SessionStart` /
68
- `hooks.PostToolUse` / `hooks.SubagentStart` for an entry already calling
69
- `pre-compact-snapshot.mjs` / `session-start-reinject.mjs` / `adoption-log.mjs`
90
+ `hooks.PostToolUse` / `hooks.SubagentStart` / `hooks.UserPromptSubmit` for an
91
+ entry already calling `pre-compact-snapshot.mjs` / `session-start-reinject.mjs` /
92
+ `adoption-log.mjs` / `context-reminder.mjs`
70
93
  (idempotency: skip if present — `adoption-log.mjs` is registered under both
71
94
  `PostToolUse` and `SubagentStart`) and `permissions.allow` for entries already
72
95
  covering the detected build/test/lint commands.
@@ -86,6 +109,9 @@ covering the detected build/test/lint commands.
86
109
  `phase-workflow` are present in `~/.claude/skills/` (they ship with this tool).
87
110
  4. **`.mcp.json`** — **merge**. Add a semantic-search server only if none was
88
111
  detected in any scope. Preserve every existing server and key.
112
+ *(Skill path only — the `tune-context` CLI reads `.mcp.json` but never writes
113
+ it: picking which server to add is a judgment call, and the CLI runs with no
114
+ model in the loop.)*
89
115
  5. **Hooks** — ensure `~/.claude/hooks/pre-compact-snapshot.mjs`,
90
116
  `~/.claude/hooks/session-start-reinject.mjs` and `~/.claude/hooks/adoption-log.mjs`
91
117
  exist (they ship with this tool, installed once user-level — same as the
@@ -95,10 +121,10 @@ covering the detected build/test/lint commands.
95
121
  Install the hooks directory as a set, never single files. Merge into `settings.json` from
96
122
  `${CLAUDE_SKILL_DIR}/templates/settings.json.tmpl`: append the `PreCompact`
97
123
  (matcher `"*"`, docs: omit or `*` = every trigger), `SessionStart`
98
- (matcher `"compact"`), `PostToolUse` (matcher `"*"`) and `SubagentStart`
99
- (matcher `"*"`) entries **only if an entry calling that script isn't already
100
- there** — treat each hook array as a set keyed by `command`, not a value to
101
- overwrite.
124
+ (matcher `"compact"`), `PostToolUse` (matcher `"*"`), `SubagentStart`
125
+ (matcher `"*"`) and `UserPromptSubmit` (matcher `"*"`) entries **only if an
126
+ entry calling that script isn't already there** — treat each hook array as a
127
+ set keyed by `command`, not a value to overwrite.
102
128
  - The template's `{{HOOKS_DIR}}` slot decides where those commands point.
103
129
  **Default: `$HOME/.claude/hooks`** — keep the literal `$HOME` (expanded when
104
130
  the hook fires, so `settings.json` stays portable between machines).
@@ -122,14 +148,30 @@ covering the detected build/test/lint commands.
122
148
  contracts-first edits) is likewise not `Skill`-call-visible — the verdict
123
149
  reports zero `Skill` calls as a neutral note, not "unused", and points at the
124
150
  edit:read ratio as a partial proxy.
125
- - Both state paths resolve through `hooks/config-dir.mjs`'s `claudeBaseDir()` —
126
- same `CLAUDE_CONFIG_DIR || ~/.claude` rule as the registration paths above, so
127
- an override isolates state too, not just script locations.
151
+ - `context-reminder.mjs` is registered under `UserPromptSubmit` (matcher `"*"`).
152
+ It reads the **tail** of the session transcript (`transcript_path`, present on
153
+ every hook payload), sums `input_tokens + cache_read + cache_creation` — the
154
+ context actually re-sent — and once that crosses a PO-frozen threshold
155
+ (120k, override `TUNE_CONTEXT_REMIND_TOKENS`) injects one short reminder to
156
+ consider `/compact`. Fires **once per crossing**, re-arming only after a
157
+ compaction-sized drop: the reminder costs tokens on the prompt it appears in,
158
+ so nagging every turn would spend exactly what the tool saves. It reminds on
159
+ SIZE, not on "phase boundary" — size is measurable from the transcript, a
160
+ phase boundary is a judgment a hook (no model access) cannot make, so the
161
+ reminder hands that timing call to the model and `phase-workflow`.
162
+ - All three state paths resolve through `hooks/config-dir.mjs`'s
163
+ `claudeBaseDir()` — same `CLAUDE_CONFIG_DIR || ~/.claude` rule as the
164
+ registration paths above, so an override isolates state too, not just script
165
+ locations.
128
166
  6. **`settings.json` permissions** — merge `permissions.allow`: the project's
129
167
  safe, frequent commands (build/test/lint, `git status`/`diff`/`log`) and
130
- `mcp__<server>__<tool>` for detected servers. Union with the existing array
168
+ `mcp__<server>__<tool>` for detected servers **excluding servers rejected
169
+ via `disabledMcpjsonServers`** (see §1). Union with the existing array
131
170
  (dedupe, never remove entries). Never add blanket `Bash(*)` or bypass modes.
132
171
  7. **Wiki** — wire the lever chosen in detection (segment / MCP note / pointer).
172
+ *(Skill path only — the CLI neither detects the wiki location nor wires this
173
+ lever; choosing between a RAG segment, a dedicated MCP and a plain pointer
174
+ needs judgment.)*
133
175
 
134
176
  Keep the generated CLAUDE.md **thin**: headings and pointers only. Depth belongs
135
177
  in skills — that is the whole point of this setup.
@@ -149,10 +191,11 @@ in skills — that is the whole point of this setup.
149
191
  should exit 0 without throwing; same for `adoption-log.mjs`).
150
192
  - **The merge actually landed — don't just trust step 5 ran.** Re-read the
151
193
  target `settings.json` (the same file you merged into) and parse it: confirm
152
- `hooks.PreCompact`, `hooks.SessionStart`, `hooks.PostToolUse` and
153
- `hooks.SubagentStart` each contain an entry whose `command` references the
154
- expected script (`pre-compact-snapshot.mjs` / `session-start-reinject.mjs` /
155
- `adoption-log.mjs` twice, for `PostToolUse` and `SubagentStart`). Copying the
194
+ `hooks.PreCompact`, `hooks.SessionStart`, `hooks.PostToolUse`,
195
+ `hooks.SubagentStart` and `hooks.UserPromptSubmit` each contain an entry whose
196
+ `command` references the expected script (`pre-compact-snapshot.mjs` /
197
+ `session-start-reinject.mjs` / `adoption-log.mjs` twice, for `PostToolUse` and
198
+ `SubagentStart` / `context-reminder.mjs`). Copying the
156
199
  hook scripts to `~/.claude/hooks/` is not sufficient — a real prior incident
157
200
  had the scripts present and executable while `settings.json` had **no**
158
201
  `hooks` key at all, so the hooks silently never fired. Scripts-exist and
@@ -43,16 +43,17 @@
43
43
  }
44
44
  ]
45
45
  }
46
- ]
47
- },
48
- "permissions": {
49
- "allow": [
50
- "Bash({{BUILD_CMD}}:*)",
51
- "Bash({{TEST_CMD}}:*)",
52
- "Bash({{LINT_CMD}}:*)",
53
- "Bash(git status:*)",
54
- "Bash(git diff:*)",
55
- "Bash(git log:*)"
46
+ ],
47
+ "UserPromptSubmit": [
48
+ {
49
+ "matcher": "*",
50
+ "hooks": [
51
+ {
52
+ "type": "command",
53
+ "command": "node \"{{HOOKS_DIR}}/context-reminder.mjs\""
54
+ }
55
+ ]
56
+ }
56
57
  ]
57
58
  }
58
59
  }