@bglocation/tune-context 1.0.1 → 1.1.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.
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "name": "bglocation",
3
+ "description": "Context-engineering tooling for Claude Code from bglocation — token-saving doctrine, configurators and semantic-search wiring.",
3
4
  "owner": {
4
5
  "name": "Szymon Walczak",
5
6
  "url": "https://bglocation.dev"
@@ -9,7 +10,7 @@
9
10
  "name": "tune-context",
10
11
  "source": "./",
11
12
  "description": "Context-efficiency configurator for Claude Code: doctrine skills (token-efficiency, caveman, cdd, phase-workflow) plus a tune-context configurator that scaffolds a lean CLAUDE.md, subagents, settings and hooks.",
12
- "version": "1.0.1",
13
+ "version": "1.1.0",
13
14
  "author": {
14
15
  "name": "Szymon Walczak",
15
16
  "url": "https://bglocation.dev"
@@ -2,7 +2,7 @@
2
2
  "name": "tune-context",
3
3
  "displayName": "tune-context",
4
4
  "description": "Context-efficiency configurator for Claude Code: doctrine skills (token-efficiency, caveman, cdd, phase-workflow) plus a tune-context configurator that scaffolds a lean CLAUDE.md, subagents, settings and hooks.",
5
- "version": "1.0.1",
5
+ "version": "1.1.0",
6
6
  "author": {
7
7
  "name": "Szymon Walczak",
8
8
  "url": "https://bglocation.dev"
package/CHANGELOG.md ADDED
@@ -0,0 +1,92 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@bglocation/tune-context` are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.1.0] — 2026-07-24
9
+
10
+ Two hardening epics land together: consistent `CLAUDE_CONFIG_DIR` support with the
11
+ guards to keep it honest (EPIC-006), and an adoption report that only claims what
12
+ the log can actually support (EPIC-007). Default-path behavior is unchanged — no
13
+ action needed on upgrade.
14
+
15
+ ### Added
16
+
17
+ - **`CLAUDE_CONFIG_DIR` honored across the whole hook lifecycle.** Setting Claude
18
+ Code's `CLAUDE_CONFIG_DIR` now moves *everything* user-level under the override —
19
+ not just the config dir, but registered hook command paths and hook **state**
20
+ (PreCompact snapshots, adoption log) too. Previously the override leaked: scripts
21
+ landed under `/x/`, but hook commands and state still pointed at `~/.claude`.
22
+ Documented in the README with a per-path table. (EPIC-006 / TASK-064, TASK-067)
23
+ - **Golden-fixture test harness** for the adoption report's pure functions
24
+ (`parseLog` / `aggregate` / `formatReport`), run by `npm test` and wired into the
25
+ `prepublishOnly` gate. Deliberately wrong aggregation now fails a fixture instead
26
+ of shipping. (EPIC-007 / TASK-068)
27
+ - **edit:read ratio** in the adoption report — a partial, *ungraded* proxy for
28
+ doctrine applied as method (narrow reads, contracts-first edits), the discipline
29
+ that leaves no `Skill` tool call to count. (EPIC-007 / TASK-071)
30
+
31
+ ### Changed
32
+
33
+ - **`verify` cross-checks hook wiring.** `cli/verify.mjs` now confirms each
34
+ registered hook command points at the file actually installed — checking *all*
35
+ matching entries, not just the first — so a dead or mis-pointed duplicate fails
36
+ red instead of passing on a partial match. (EPIC-006 / TASK-065)
37
+ - **Adoption report splits code search from shell plumbing.** Semantic search (RAG)
38
+ and the Grep tool count as code search; `grep`/`find` inside Bash is reported
39
+ separately and kept out of the RAG share. Removes the "RAG 0%" false negative on
40
+ logs that were only ever doing operational greps. (EPIC-007 / TASK-069)
41
+ - **The adoption verdict is now earned.** RAG and doctrine-skill judgments are
42
+ withheld below a PO-frozen floor of 3 distinct sessions ("insufficient signal"
43
+ instead of a confident ✗ over one atypical session). A lever the log genuinely
44
+ can't distinguish — RAG installed-but-unused vs not installed, or method-level
45
+ doctrine that leaves no tool call — is reported neutrally, never as a false ✗.
46
+ (EPIC-007 / TASK-070, TASK-071)
47
+
48
+ ### Fixed
49
+
50
+ - **Manual hook-install docs were incomplete.** README / SKILL.md now instruct
51
+ copying `hooks/config-dir.mjs` alongside `hooks/adoption-log.mjs` — the latter
52
+ imports the former, so a by-hand install following the old docs threw
53
+ `Cannot find module`. (EPIC-006)
54
+ - **No more false churn/verdict lines over empty data.** The adoption report no
55
+ longer prints "elevated re-read churn" for a zero-read log, and no longer dangles
56
+ a "see verdict below" pointer when the verdict section has nothing to say.
57
+ (EPIC-007 / TASK-070, TASK-071)
58
+
59
+ ### Testing / packaging
60
+
61
+ - `smoke:init` now exercises **both** the default `$HOME` and the
62
+ `CLAUDE_CONFIG_DIR` override paths, asserting hook state round-trips in each.
63
+ (EPIC-006 / TASK-066)
64
+ - `npm test` (the golden-fixture suite) runs first in `prepublishOnly`, ahead of
65
+ `verify:pack` / `verify:plugin` / `smoke:init` — cheapest gate, fails fastest.
66
+ (EPIC-007 / TASK-068)
67
+
68
+ ## [1.0.1] — 2026-07-23
69
+
70
+ ### Fixed
71
+
72
+ - Stop shipping the dev-only MCP config (`.mcp.json`, `rag.config.json`) to plugin
73
+ installers. Because the repo root doubles as the plugin root, a tracked
74
+ `.mcp.json` would force every marketplace install to load a `rag` server pointing
75
+ at a binary and index the installer doesn't have — breaking the "RAG is optional,
76
+ detected never assumed" contract. Now enforced by `verify:plugin`.
77
+
78
+ ## [1.0.0] — 2026-07-23
79
+
80
+ ### Added
81
+
82
+ - Initial dual-channel release under `@bglocation`: a Claude Code **plugin**
83
+ (marketplace) and an **npm CLI** (`tune-context init`), from one self-contained
84
+ repo. Ships the doctrine skills (`token-efficiency`, `caveman`, `cdd`,
85
+ `phase-workflow`), the `tune-context` configurator (skill + deterministic CLI
86
+ twin with a never-clobber managed-block contract), right-sized subagents
87
+ (`explore-cheap`, `reviewer`), the PreCompact / SessionStart / PostToolUse hook
88
+ suite, and the adoption report (`eval/adoption-report.mjs`).
89
+
90
+ [1.1.0]: https://gitlab.com/bglocation/tune-context/-/tags/v1.1.0
91
+ [1.0.1]: https://gitlab.com/bglocation/tune-context/-/tags/v1.0.1
92
+ [1.0.0]: https://gitlab.com/bglocation/tune-context/-/tags/v1.0.0
package/README.md CHANGED
@@ -110,11 +110,35 @@ The plugin manifest (`.claude-plugin/plugin.json`) and hook wiring
110
110
  automatically; `bin/tune-context` is added to `PATH` while the plugin is enabled.
111
111
  The marketplace catalog is [`.claude-plugin/marketplace.json`](.claude-plugin/marketplace.json).
112
112
 
113
+ ### `CLAUDE_CONFIG_DIR`
114
+
115
+ By default the CLI reads and writes Claude config under `~/.claude` (and
116
+ `~/.claude.json`). If you set Claude Code's `CLAUDE_CONFIG_DIR`, `tune-context
117
+ init` honors it consistently — everything user-level moves under the override:
118
+
119
+ | What | Default | With `CLAUDE_CONFIG_DIR=/x` |
120
+ |---|---|---|
121
+ | doctrine skills / agents / hook scripts | `~/.claude/…` | `/x/…` |
122
+ | `settings.json` (hooks + gating) | `~/.claude/settings.json` | `/x/settings.json` |
123
+ | MCP-server detection (`.claude.json`) | `~/.claude.json` | `/x/.claude.json` |
124
+ | registered hook command paths | `$HOME/.claude/hooks/…` | `/x/hooks/…` |
125
+ | hook state (PreCompact snapshot, adoption log) | `~/.claude/tune-context/…` | `/x/tune-context/…` |
126
+
127
+ Without the override the generated `settings.json` keeps the portable
128
+ `$HOME/.claude/hooks/…` form (expanded when the hook fires, so it travels between
129
+ machines). Under the override it's baked to the literal `/x/hooks/…` — where the
130
+ scripts actually land. Useful for shared accounts, containers, and test isolation.
131
+
113
132
  ## Measuring adoption (does the doctrine actually get used?)
114
133
  `hooks/adoption-log.mjs` logs one JSONL line per event to
115
- `~/.claude/tune-context/adoption.jsonl`, and `eval/adoption-report.mjs` turns that
116
- into a report: RAG vs grep, doctrine skills, subagents, re-read churn — with a
117
- verdict per lever. It's registered under two Claude Code hook events
134
+ `~/.claude/tune-context/adoption.jsonl` by default (under `CLAUDE_CONFIG_DIR`, the
135
+ override's `tune-context/adoption.jsonl` see above), and `eval/adoption-report.mjs`
136
+ turns that into a report code search (semantic RAG vs the Grep tool, with
137
+ shell-plumbing greps kept out of the RAG share), doctrine skills, subagents, and
138
+ re-read churn — closing with an *earned* verdict: withheld below a PO-frozen floor of
139
+ distinct sessions, and never a false ✗ for a lever the log can't see (RAG
140
+ installed-but-unused vs not installed; method-level doctrine that leaves no tool call).
141
+ It's registered under two Claude Code hook events —
118
142
  `PostToolUse` (every tool call) and `SubagentStart` (subagent spawns, which aren't
119
143
  regular tools).
120
144
 
@@ -123,8 +147,8 @@ regular tools).
123
147
  1. **Install the hook.** Either run **`/tune-context`** in your repo (it copies the
124
148
  script to `~/.claude/hooks/` and merges the hook entries into `settings.json`
125
149
  for you), or do it by hand:
126
- - copy `hooks/adoption-log.mjs` `~/.claude/hooks/adoption-log.mjs` (needs
127
- Node ≥ 18 on `PATH`);
150
+ - copy `hooks/adoption-log.mjs` **and** `hooks/config-dir.mjs` (it imports the
151
+ latter for the log path) → `~/.claude/hooks/` (needs Node ≥ 18 on `PATH`);
128
152
  - add the `PostToolUse` **and** `SubagentStart` blocks from
129
153
  [`skills/tune-context/templates/settings.json.tmpl`](skills/tune-context/templates/settings.json.tmpl)
130
154
  to your `settings.json` — `~/.claude/settings.json` for every project, or a
@@ -137,7 +161,8 @@ regular tools).
137
161
  the `/hooks` command.
138
162
 
139
163
  3. **Just work.** Every tool call and subagent run now appends a line to
140
- `~/.claude/tune-context/adoption.jsonl` (created on first write).
164
+ `~/.claude/tune-context/adoption.jsonl` by default (created on first write; under
165
+ `CLAUDE_CONFIG_DIR` it's the override's `tune-context/adoption.jsonl` instead).
141
166
 
142
167
  4. **Read the report** any time, from this checkout (or the installed package):
143
168
  ```bash
@@ -147,13 +172,16 @@ regular tools).
147
172
 
148
173
  **Stop / opt out:** delete the `PostToolUse` + `SubagentStart` entries from
149
174
  `settings.json` (watcher picks that up too). The log stays local under
150
- `~/.claude/tune-context/` and records only tool names, file paths, and
151
- skill/agent names — never full Bash command strings.
175
+ `~/.claude/tune-context/` (or the `CLAUDE_CONFIG_DIR` override) and records only
176
+ tool names, file paths, and skill/agent names — never full Bash command strings.
152
177
 
153
178
  **Honest scope:** this is a *behavioral proxy*, not a bill. Token cost lands only
154
179
  in OTEL (`CLAUDE_CODE_ENABLE_TELEMETRY=1` → `claude_code.cost.usage`, needs a
155
180
  collector), never a hook; and caveman is an output style with no tool call, so it
156
- can't be counted this way.
181
+ can't be counted this way. Doctrine applied as *method* — narrow reads,
182
+ contracts-first edits, delegation — is only partly visible too: no `Skill` call,
183
+ but its trace shows up in the report's edit:read ratio, so the verdict never
184
+ reads zero `Skill` calls as "unused."
157
185
 
158
186
  ### Getting real cost (OTEL) — outside tune-context
159
187
 
@@ -2,7 +2,6 @@
2
2
  // tune-context init — deterministic detect -> generate -> verify.
3
3
  // This is the CLI/plugin-bin twin of skills/tune-context/SKILL.md: same
4
4
  // managed-block rules, same "never clobber" guarantee, no model in the loop.
5
- import { readFileSync } from 'node:fs';
6
5
  import { dirname, join, resolve } from 'node:path';
7
6
  import { fileURLToPath } from 'node:url';
8
7
  import { claudeDirs, detectExistingConfig, detectMcpServers, detectStack } from '../cli/detect.mjs';
@@ -11,6 +10,7 @@ import {
11
10
  generateProjectClaudeMd,
12
11
  generateUserClaudeMd,
13
12
  installDoctrine,
13
+ loadSettingsTemplate,
14
14
  mergeHooks,
15
15
  mergePermissions,
16
16
  } from '../cli/generate.mjs';
@@ -47,7 +47,7 @@ function runInit(cwd) {
47
47
  const userClaudeMd = generateUserClaudeMd(claudeDir, templatesDir);
48
48
  const projectClaudeMd = generateProjectClaudeMd(cwd, detection, templatesDir);
49
49
 
50
- const settingsTemplate = JSON.parse(readFileSync(join(templatesDir, 'settings.json.tmpl'), 'utf8'));
50
+ const settingsTemplate = loadSettingsTemplate(templatesDir);
51
51
  const hooksAdded = mergeHooks(existing.userSettingsJson, { hooks: settingsTemplate.hooks });
52
52
  const permissionsAllow = buildPermissionsAllow(detection);
53
53
  const permissionsAdded = mergePermissions(existing.projectSettingsJson, permissionsAllow);
package/cli/generate.mjs CHANGED
@@ -7,6 +7,48 @@ import { dirname, join } from 'node:path';
7
7
  import { syncDoctrine } from './sync-doctrine.mjs';
8
8
  import { upsertManagedBlock } from './managed-block.mjs';
9
9
 
10
+ /**
11
+ * The hooks directory baked into settings.json commands.
12
+ *
13
+ * Default stays the *portable* `$HOME/.claude/hooks` form — `$HOME` is expanded
14
+ * by the shell when the hook fires, so settings.json can be carried between
15
+ * machines. This must be byte-identical to what shipped before {{HOOKS_DIR}}
16
+ * existed, or every current user sees a spurious diff.
17
+ *
18
+ * Under CLAUDE_CONFIG_DIR it must instead be the *literal* `<override>/hooks`
19
+ * path, because that is where syncDoctrine actually installs the scripts
20
+ * (claudeDir == the override). A hardcoded `$HOME/.claude/hooks` there points at
21
+ * a dir the scripts were never copied to, and the hooks silently never fire —
22
+ * the bug this task fixes.
23
+ */
24
+ export function hooksDirForTemplate(env = process.env) {
25
+ const override = env.CLAUDE_CONFIG_DIR;
26
+ return override ? join(override, 'hooks') : '$HOME/.claude/hooks';
27
+ }
28
+
29
+ /**
30
+ * Read settings.json.tmpl and fill the {{HOOKS_DIR}} slot in each hook command.
31
+ *
32
+ * Parse first, then substitute into the parsed command strings — never inject a
33
+ * filesystem path into JSON *source*. A CLAUDE_CONFIG_DIR containing a backslash
34
+ * or a quote (both legal in a posix dir name) would otherwise land inside the
35
+ * JSON text and throw an opaque `Bad escaped character in JSON` on parse. The
36
+ * `{{HOOKS_DIR}}` placeholder itself has no JSON-special chars, so the template
37
+ * parses cleanly and only the parsed strings ever see the real path.
38
+ */
39
+ export function loadSettingsTemplate(templatesDir, env = process.env) {
40
+ const template = JSON.parse(readFileSync(join(templatesDir, 'settings.json.tmpl'), 'utf8'));
41
+ const hooksDir = hooksDirForTemplate(env);
42
+ for (const groups of Object.values(template.hooks || {})) {
43
+ for (const group of groups) {
44
+ for (const hook of group.hooks || []) {
45
+ if (typeof hook.command === 'string') hook.command = hook.command.replaceAll('{{HOOKS_DIR}}', hooksDir);
46
+ }
47
+ }
48
+ }
49
+ return template;
50
+ }
51
+
10
52
  function readJson(path, fallback) {
11
53
  if (!existsSync(path)) return fallback;
12
54
  try {
package/cli/verify.mjs CHANGED
@@ -2,7 +2,8 @@
2
2
  // landed" per SKILL.md §3. Never trust that a write succeeded just because
3
3
  // the function returned; re-read the target file and parse it.
4
4
  import { existsSync, readFileSync, statSync } from 'node:fs';
5
- import { join } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+ import { basename, join, resolve } from 'node:path';
6
7
 
7
8
  function readJson(path) {
8
9
  try {
@@ -12,17 +13,87 @@ function readJson(path) {
12
13
  }
13
14
  }
14
15
 
16
+ // Pull the script path out of a hook command string. Commands look like
17
+ // node "$HOME/.claude/hooks/pre-compact-snapshot.mjs"
18
+ // so the target is the first single- or double-quoted argument. Returns null
19
+ // when nothing is quoted — the caller must treat that as "cannot verify", never
20
+ // as a silent pass. A false alarm here is cheaper than a missed broken wire.
21
+ function extractHookPath(command) {
22
+ if (typeof command !== 'string') return null;
23
+ const m = command.match(/"([^"]+)"|'([^']+)'/);
24
+ return m ? (m[1] ?? m[2]) : null;
25
+ }
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.
31
+ function expandHome(p) {
32
+ const home = process.env.HOME || homedir();
33
+ return p.replace(/\$\{HOME\}/g, home).replace(/\$HOME/g, home);
34
+ }
35
+
15
36
  /**
16
37
  * @returns {{ok: boolean, checks: {name: string, ok: boolean, detail?: string}[]}}
17
38
  */
18
39
  export function verify({ claudeDir, userSettingsPath, expectedHookCommands, projectSettingsPath, expectedPermissions }) {
19
40
  const checks = [];
20
41
 
42
+ // Cross-check, not two independent presence checks. The old code asked
43
+ // "does some command contain the script name?" and, separately, "does the
44
+ // script exist in claudeDir?" — both could pass while pointing at different
45
+ // dirs (the CLAUDE_CONFIG_DIR bug: script installed under the override, but
46
+ // the command still hardcoded to $HOME/.claude/hooks). Here the registered
47
+ // command must resolve to *the same file* we installed.
21
48
  const settings = readJson(userSettingsPath);
22
49
  for (const { event, scriptName } of expectedHookCommands) {
23
50
  const entries = settings?.hooks?.[event] || [];
24
- const ok = entries.some((g) => g.hooks?.some((h) => h.command?.includes(scriptName)));
25
- checks.push({ name: `hooks.${event} registers ${scriptName}`, ok, detail: ok ? undefined : `not found in ${userSettingsPath}` });
51
+ const commands = entries.flatMap((g) => (g.hooks || []).map((h) => h.command)).filter(Boolean);
52
+ const installedPath = resolve(join(claudeDir, 'hooks', scriptName));
53
+
54
+ // Match by the basename of each command's target path — ALL of them, not
55
+ // just the first. mergeHooks is a set keyed by command, so a path change
56
+ // (e.g. adopting CLAUDE_CONFIG_DIR) appends a new entry next to the old
57
+ // one, and Claude Code runs every registered command: a leftover pointing
58
+ // at a missing file errors on every event. First-match-only had two holes:
59
+ // stale-first hid a correct entry behind the pre-fix error message, and
60
+ // correct-first passed silently over a dead leftover.
61
+ const matching = commands.filter((c) => basename(extractHookPath(c) || '') === scriptName);
62
+
63
+ if (matching.length === 0) {
64
+ // Fall back to a substring test so a present-but-unparseable command is
65
+ // reported loudly rather than mistaken for "not registered at all".
66
+ const fallback = commands.find((c) => c.includes(scriptName));
67
+ if (!fallback) {
68
+ checks.push({ name: `hooks.${event} registers ${scriptName}`, ok: false, detail: `not found in ${userSettingsPath}` });
69
+ } else {
70
+ checks.push({ name: `hooks.${event} → ${scriptName} points at installed file`, ok: false, detail: `could not parse a path from command: ${fallback}` });
71
+ }
72
+ continue;
73
+ }
74
+
75
+ const resolved = matching.map((c) => resolve(expandHome(extractHookPath(c))));
76
+ const strays = resolved.filter((p) => p !== installedPath);
77
+ const deadStrays = strays.filter((p) => !existsSync(p));
78
+
79
+ let ok;
80
+ let detail;
81
+ if (!resolved.includes(installedPath)) {
82
+ ok = false;
83
+ detail = `registered ${resolved[0]} != installed ${installedPath}`;
84
+ } else if (deadStrays.length) {
85
+ // The wiring we installed is correct, but a stale duplicate points at
86
+ // nothing — a dead wire that errors at run time. Red, with a message
87
+ // distinguishable from "the fix didn't land".
88
+ ok = false;
89
+ detail = `correct registration present, but stale entry points at missing file: ${deadStrays.join(', ')} — remove it from ${userSettingsPath}`;
90
+ } else {
91
+ // Duplicates that resolve to an existing file run twice but don't break;
92
+ // surface them so the choice is visible, not silent.
93
+ ok = true;
94
+ detail = strays.length ? `note: also registered at ${strays.join(', ')}` : undefined;
95
+ }
96
+ checks.push({ name: `hooks.${event} → ${scriptName} points at installed file`, ok, detail });
26
97
  }
27
98
 
28
99
  const scriptNames = [...new Set(expectedHookCommands.map((h) => h.scriptName))];
@@ -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,16 @@ 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
+
25
33
  function defaultLogPath() {
26
- return join(homedir(), '.claude', 'tune-context', 'adoption.jsonl');
34
+ return join(claudeBaseDir(), 'tune-context', 'adoption.jsonl');
27
35
  }
28
36
 
29
37
  const USAGE = `
@@ -33,7 +41,8 @@ Usage:
33
41
  adoption-report [--log <path>]
34
42
 
35
43
  Options:
36
- --log <path> Path to adoption.jsonl (default: ~/.claude/tune-context/adoption.jsonl)
44
+ --log <path> Path to adoption.jsonl (default: <config dir>/tune-context/adoption.jsonl,
45
+ where <config dir> is $CLAUDE_CONFIG_DIR if set, else ~/.claude)
37
46
  -h, --help Show this help
38
47
 
39
48
  The log is written by the adoption-log.mjs PostToolUse hook (installed by
@@ -56,10 +65,12 @@ export function parseLog(text) {
56
65
  }
57
66
 
58
67
  export function aggregate(records) {
59
- const search = { rag: 0, text: 0, glob: 0 };
68
+ const search = { rag: 0, grepTool: 0, shell: 0, glob: 0 };
60
69
  const skills = new Map();
61
70
  const subagents = new Map();
62
- const readCounts = new Map(); // detail(file) -> times read
71
+ const readCounts = new Map(); // `${session}\u0000${file}` -> times read in that session
72
+ const sessionSet = new Set();
73
+ const cwdSet = new Set();
63
74
  let reads = 0;
64
75
  let edits = 0;
65
76
  let bash = 0;
@@ -68,12 +79,24 @@ export function aggregate(records) {
68
79
  const bump = (m, k) => m.set(k, (m.get(k) ?? 0) + 1);
69
80
 
70
81
  for (const r of records) {
82
+ if (r.session != null) sessionSet.add(r.session);
83
+ if (r.cwd != null) cwdSet.add(r.cwd);
71
84
  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++;
85
+ case 'search': {
86
+ // Split content search by TOOL — the only reliable "is this a codebase
87
+ // search?" signal. RAG (search_codebase) and the Grep tool are
88
+ // unambiguous code search; a grep/find inside Bash is usually plumbing
89
+ // (git … | grep, npm pack | grep) and must NOT compete with RAG. The
90
+ // record's `tool` field (set by the logger's buildRecord) carries this,
91
+ // so the split reinterprets existing logs — no format change, hook
92
+ // untouched. See TASK-069.
93
+ const tool = typeof r.tool === 'string' ? r.tool : '';
94
+ if (r.detail === 'rag' || tool.includes('search_codebase')) search.rag++;
95
+ else if (r.detail === 'glob' || tool === 'Glob') search.glob++;
96
+ else if (tool === 'Grep') search.grepTool++;
97
+ else search.shell++; // Bash-grep / ambiguous — reported, excluded from RAG-share
76
98
  break;
99
+ }
77
100
  case 'skill':
78
101
  bump(skills, r.detail ?? '(unknown)');
79
102
  break;
@@ -82,7 +105,11 @@ export function aggregate(records) {
82
105
  break;
83
106
  case 'read':
84
107
  reads++;
85
- if (r.detail) bump(readCounts, r.detail);
108
+ // Key re-read churn by (session, file): the same file opened in two
109
+ // independent sessions is NOT lost context — only re-opening within one
110
+ // session is. Per-file-global inflates churn on multi-session logs
111
+ // (TASK-070). Single-session logs are unchanged (session is constant).
112
+ if (r.detail) bump(readCounts, `${r.session ?? ''}\u0000${r.detail}`);
86
113
  break;
87
114
  case 'edit':
88
115
  edits++;
@@ -95,8 +122,11 @@ export function aggregate(records) {
95
122
  }
96
123
  }
97
124
 
98
- const contentSearches = search.rag + search.text;
99
- const ragShare = contentSearches > 0 ? search.rag / contentSearches : null;
125
+ // RAG-share denominator is ONLY unambiguous code search (RAG + Grep tool);
126
+ // shell plumbing is excluded. null (not 0) when there was no code search at
127
+ // all — "n/a", not "✗ weak", so an all-plumbing log stops reading as failure.
128
+ const codeSearches = search.rag + search.grepTool;
129
+ const ragShare = codeSearches > 0 ? search.rag / codeSearches : null;
100
130
 
101
131
  // Re-read churn proxy: files opened more than once, and the extra opens beyond
102
132
  // the first. High churn hints the model is re-fetching context it lost — the
@@ -112,6 +142,8 @@ export function aggregate(records) {
112
142
 
113
143
  return {
114
144
  total: records.length,
145
+ sessions: sessionSet.size,
146
+ cwds: cwdSet.size,
115
147
  search,
116
148
  ragShare,
117
149
  skills: [...skills.entries()].sort((a, b) => b[1] - a[1]),
@@ -123,6 +155,14 @@ export function aggregate(records) {
123
155
  reReadFiles,
124
156
  redundantReads,
125
157
  reReadRate: reads > 0 ? redundantReads / reads : null,
158
+ // Doctrine applied as *method* — narrow reads, contracts-first edits,
159
+ // delegation — leaves no `Skill` tool call, so the skills verdict below
160
+ // can't see it (same blind spot the report already admits for caveman).
161
+ // Edits per read is a partial, honest echo of that method: editing without
162
+ // re-reading first is what narrow-read discipline looks like from the
163
+ // outside. Not a verdict — a read-heavy exploration session scores low here
164
+ // for reasons unrelated to doctrine — so it is shown, never graded (TASK-071).
165
+ editReadRatio: reads > 0 ? edits / reads : null,
126
166
  };
127
167
  }
128
168
 
@@ -130,21 +170,27 @@ function pct(x) {
130
170
  return x === null ? 'n/a' : `${Math.round(x * 100)}%`;
131
171
  }
132
172
 
173
+ function ratio(x) {
174
+ return x === null ? 'n/a' : `${x.toFixed(1)}:1`;
175
+ }
176
+
133
177
  export function formatReport(a) {
134
178
  const L = [];
135
179
  L.push('tune-context Adoption Report');
136
180
  L.push('============================');
137
181
  L.push('');
138
182
  L.push(`Tool calls logged: ${a.total}`);
183
+ L.push(`Sessions: ${a.sessions} Projects (distinct cwd): ${a.cwds}`);
139
184
  L.push('');
140
185
 
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)}`);
186
+ L.push('Search — code search vs shell plumbing:');
187
+ L.push(` semantic (RAG): ${a.search.rag}`);
188
+ L.push(` code search (Grep): ${a.search.grepTool}`);
189
+ L.push(` shell text-search: ${a.search.shell} (grep/find in Bash — operational, not in RAG-share)`);
190
+ L.push(` glob (file find): ${a.search.glob}`);
145
191
  L.push(
146
- ` RAG share of content search: ${pct(a.ragShare)}` +
147
- (a.ragShare === null ? ' (no content searches yet)' : ''),
192
+ ` RAG share of code search: ${pct(a.ragShare)}` +
193
+ (a.ragShare === null ? ' (no code search — RAG or Grep — observed)' : ''),
148
194
  );
149
195
  L.push('');
150
196
 
@@ -153,7 +199,10 @@ export function formatReport(a) {
153
199
  const mark = DOCTRINE_SKILLS.has(name) ? '★' : ' ';
154
200
  L.push(` ${mark} ${String(n).padStart(3)}× ${name}`);
155
201
  }
156
- if (a.skills.length === 0) L.push(' (none doctrine skills unused)');
202
+ // No forward reference to "the verdict below" here: on a totally empty log
203
+ // (total === 0) the verdict section only prints "No data yet" and never
204
+ // mentions skills at all — a "see verdict below" promise would dangle.
205
+ if (a.skills.length === 0) L.push(' (none via the Skill tool)');
157
206
  L.push('');
158
207
 
159
208
  L.push(`Subagents spawned: ${a.subagents.length} distinct`);
@@ -169,6 +218,10 @@ export function formatReport(a) {
169
218
  ` re-read files: ${a.reReadFiles} redundant opens: ${a.redundantReads}` +
170
219
  ` (${pct(a.reReadRate)} of reads)`,
171
220
  );
221
+ L.push(
222
+ ` edit:read ratio: ${ratio(a.editReadRatio)} — edits per read; narrow,` +
223
+ ' contracts-first edits (doctrine-as-method, not tool-call-visible) push this up.',
224
+ );
172
225
  L.push('');
173
226
 
174
227
  // Verdict — behavioral, not a cost claim. Frozen thresholds so the model
@@ -177,23 +230,58 @@ export function formatReport(a) {
177
230
  if (a.total === 0) {
178
231
  L.push(' No data yet. Run some sessions with the hook installed, then re-run.');
179
232
  } else {
180
- if (a.ragShare !== null) {
233
+ // RAG + doctrine skills are cross-session tendencies: gate them on sample
234
+ // size so a single atypical session can't read as a trend. Below the
235
+ // PO-frozen floor, withhold both judgments (TASK-070).
236
+ if (a.sessions < MIN_SESSIONS_FOR_VERDICT) {
237
+ L.push(
238
+ ` ~ RAG/skills verdict withheld — needs ≥${MIN_SESSIONS_FOR_VERDICT} distinct sessions, have ${a.sessions}.` +
239
+ ' One session is a sample, not a trend; collect more, then re-run.',
240
+ );
241
+ } else {
242
+ if (a.search.rag === 0) {
243
+ // Availability (Option 1, PO 2026-07-24): search_codebase never observed
244
+ // → report as unavailable, never ✗ and never ✓. The behavioral log can't
245
+ // tell "RAG not installed here" from "installed but never reached for",
246
+ // so it must not accuse — a known, accepted blind spot (TASK-070 notes).
247
+ L.push(
248
+ ' · RAG not observed — no search_codebase in these sessions. Not counted' +
249
+ ' as ✗: can\'t tell "not installed" from "installed but unused".',
250
+ );
251
+ } else {
252
+ // rag > 0 ⇒ denominator (rag + grepTool) > 0 ⇒ ragShare is a number.
253
+ L.push(
254
+ a.ragShare >= 0.5
255
+ ? ' ✓ RAG lever working — semantic search is the default over grep.'
256
+ : ' ✗ RAG lever weak — grep still dominates code search. Revisit index/skill wiring.',
257
+ );
258
+ }
259
+ // Zero `Skill` calls is NOT proof doctrine is unused — narrow reads,
260
+ // contracts-first edits, and delegation are the method working *without*
261
+ // a tool call. A hard ✗ here would penalize exactly the discipline this
262
+ // project trains toward, so it is replaced with a neutral note pointing
263
+ // at edit:read (see the churn section above) — a partial, honest proxy,
264
+ // not a substitute verdict (TASK-071).
181
265
  L.push(
182
- a.ragShare >= 0.5
183
- ? ' ✓ RAG lever working semantic search is the default over grep.'
184
- : ' RAG lever weakgrep still dominates content search. Revisit index/skill wiring.',
266
+ a.skills.length > 0
267
+ ? ' ✓ Doctrine skills are being invoked.'
268
+ : ' · No explicit Skill invocations doctrine-as-method (narrow reads,' +
269
+ ' contracts-first, delegation) isn\'t tool-call-visible. See edit:read above.',
270
+ );
271
+ }
272
+ // Churn is a within-session symptom — valid at any sample size, so NOT gated
273
+ // on MIN_SESSIONS_FOR_VERDICT. But zero reads is zero churn signal: report
274
+ // n/a, never "elevated" — that would be a false ✗ over no data (same
275
+ // "say only what the sample supports" rule as the verdict above).
276
+ if (a.reReadRate === null) {
277
+ L.push(' · re-read churn: n/a — no reads logged yet.');
278
+ } else {
279
+ L.push(
280
+ a.reReadRate <= 0.25
281
+ ? ' ✓ Low re-read churn.'
282
+ : ' ~ Elevated re-read churn — context may be getting lost (compaction/RAG lever).',
185
283
  );
186
284
  }
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
285
  }
198
286
  L.push('');
199
287
  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
+ }
@@ -11,8 +11,8 @@
11
11
  // safety net against losing that mechanical state, not a summary.
12
12
  import { execFileSync } from 'node:child_process';
13
13
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
14
- import { homedir } from 'node:os';
15
14
  import { join } from 'node:path';
15
+ import { claudeBaseDir } from './config-dir.mjs';
16
16
 
17
17
  function readStdin() {
18
18
  try {
@@ -36,7 +36,7 @@ function git(cwd, args) {
36
36
 
37
37
  export function stateFilePath(cwd) {
38
38
  const safe = cwd.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '');
39
- return join(homedir(), '.claude', 'tune-context', 'state', `${safe}.md`);
39
+ return join(claudeBaseDir(), 'tune-context', 'state', `${safe}.md`);
40
40
  }
41
41
 
42
42
  export function buildSnapshot(input) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bglocation/tune-context",
3
- "version": "1.0.1",
3
+ "version": "1.1.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",
@@ -32,13 +32,15 @@
32
32
  "hooks",
33
33
  "eval",
34
34
  "README.md",
35
+ "CHANGELOG.md",
35
36
  "LICENSE"
36
37
  ],
37
38
  "scripts": {
39
+ "test": "node --test",
38
40
  "verify:pack": "node scripts/verify-pack.mjs",
39
41
  "verify:plugin": "node scripts/verify-plugin.mjs",
40
42
  "smoke:init": "node scripts/smoke-init.mjs",
41
- "prepublishOnly": "npm run verify:pack && npm run verify:plugin && npm run smoke:init"
43
+ "prepublishOnly": "npm test && npm run verify:pack && npm run verify:plugin && npm run smoke:init"
42
44
  },
43
45
  "publishConfig": {
44
46
  "access": "public"
@@ -89,25 +89,42 @@ covering the detected build/test/lint commands.
89
89
  5. **Hooks** — ensure `~/.claude/hooks/pre-compact-snapshot.mjs`,
90
90
  `~/.claude/hooks/session-start-reinject.mjs` and `~/.claude/hooks/adoption-log.mjs`
91
91
  exist (they ship with this tool, installed once user-level — same as the
92
- doctrine skills in step 3). Merge into `settings.json` from
92
+ doctrine skills in step 3), **plus `~/.claude/hooks/config-dir.mjs`** — the
93
+ snapshot and adoption-log scripts import it for their state paths, so
94
+ installing them without it means `Cannot find module` on every event.
95
+ Install the hooks directory as a set, never single files. Merge into `settings.json` from
93
96
  `${CLAUDE_SKILL_DIR}/templates/settings.json.tmpl`: append the `PreCompact`
94
97
  (matcher `"*"`, docs: omit or `*` = every trigger), `SessionStart`
95
98
  (matcher `"compact"`), `PostToolUse` (matcher `"*"`) and `SubagentStart`
96
99
  (matcher `"*"`) entries **only if an entry calling that script isn't already
97
100
  there** — treat each hook array as a set keyed by `command`, not a value to
98
101
  overwrite.
102
+ - The template's `{{HOOKS_DIR}}` slot decides where those commands point.
103
+ **Default: `$HOME/.claude/hooks`** — keep the literal `$HOME` (expanded when
104
+ the hook fires, so `settings.json` stays portable between machines).
105
+ **Under `CLAUDE_CONFIG_DIR`: the literal `<override>/hooks`** — that is where
106
+ the scripts are actually installed (the config dir moves, so must the path).
107
+ A hardcoded `$HOME/.claude/hooks` under an override points at a dir the
108
+ scripts were never copied to, and the hooks silently never fire. Verify with
109
+ the path cross-check in §3, not just "the command mentions the script".
99
110
  - `PreCompact` writes a mechanical git/branch/status snapshot per-project to
100
- `~/.claude/tune-context/state/`; `SessionStart(compact)` reads it back via
101
- `additionalContext`. Filesystem/git facts only — not decisions/open threads
102
- (a hook has no model access).
111
+ `~/.claude/tune-context/state/` by default; `SessionStart(compact)` reads it
112
+ back via `additionalContext`. Filesystem/git facts only — not decisions/open
113
+ threads (a hook has no model access).
103
114
  - `adoption-log.mjs` is registered under **both** `PostToolUse` (every tool
104
115
  call) and `SubagentStart` (subagent spawns — these are NOT PostToolUse tools,
105
116
  they fire their own event carrying `agent_type`). It appends one line per
106
- event to `~/.claude/tune-context/adoption.jsonl` — which lever the model
107
- reached for (RAG vs grep, Skill, subagent, re-reads). Read it with
117
+ event to `~/.claude/tune-context/adoption.jsonl` by default — which lever the
118
+ model reached for (RAG vs grep, Skill, subagent, re-reads). Read it with
108
119
  `eval/adoption-report.mjs` (the `rag-usage` analog). Behavioral proxy only:
109
120
  no token cost (needs OTEL `claude_code.cost.usage` + a collector) and no
110
- caveman (an output style, not a tool call).
121
+ caveman (an output style, not a tool call). Doctrine-as-method (narrow reads,
122
+ contracts-first edits) is likewise not `Skill`-call-visible — the verdict
123
+ reports zero `Skill` calls as a neutral note, not "unused", and points at the
124
+ 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.
111
128
  6. **`settings.json` permissions** — merge `permissions.allow`: the project's
112
129
  safe, frequent commands (build/test/lint, `git status`/`diff`/`log`) and
113
130
  `mcp__<server>__<tool>` for detected servers. Union with the existing array
@@ -140,6 +157,21 @@ in skills — that is the whole point of this setup.
140
157
  had the scripts present and executable while `settings.json` had **no**
141
158
  `hooks` key at all, so the hooks silently never fired. Scripts-exist and
142
159
  entries-are-registered are two different, both-required checks.
160
+ - **Cross-check the path, don't just match the name.** For each registered hook,
161
+ the path in its `command` must resolve to **the same file** you installed —
162
+ not merely contain the script's name. Expand `$HOME`/`${HOME}` (to
163
+ `CLAUDE_CONFIG_DIR`-aware `~/.claude`, the same base you install into),
164
+ `resolve` both sides, and compare. Under `CLAUDE_CONFIG_DIR` the script lands
165
+ in `$CLAUDE_CONFIG_DIR/hooks/` while a hardcoded `$HOME/.claude/hooks/` command
166
+ points elsewhere — both the "name is present" and "file exists" checks pass
167
+ independently while the wiring is broken. If a command can't be parsed for a
168
+ path, say so out loud; a silent OK is worse than a false alarm.
169
+ Check **every** command that names the script, not the first match: the merge
170
+ is a set keyed by command, so a path change leaves the old entry next to the
171
+ new one, and Claude Code runs both. At least one must resolve to the installed
172
+ file; any other that resolves to a **missing** file is a dead wire erroring on
173
+ every event — report it (with which file to remove), don't pass over it. A
174
+ duplicate resolving to a file that exists runs twice but works — note it.
143
175
  - **Idempotency:** a second run would produce no changes.
144
176
 
145
177
  Report what you created, what you merged, and anything you deliberately skipped.
@@ -6,7 +6,7 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "node \"$HOME/.claude/hooks/pre-compact-snapshot.mjs\""
9
+ "command": "node \"{{HOOKS_DIR}}/pre-compact-snapshot.mjs\""
10
10
  }
11
11
  ]
12
12
  }
@@ -17,7 +17,7 @@
17
17
  "hooks": [
18
18
  {
19
19
  "type": "command",
20
- "command": "node \"$HOME/.claude/hooks/session-start-reinject.mjs\""
20
+ "command": "node \"{{HOOKS_DIR}}/session-start-reinject.mjs\""
21
21
  }
22
22
  ]
23
23
  }
@@ -28,7 +28,7 @@
28
28
  "hooks": [
29
29
  {
30
30
  "type": "command",
31
- "command": "node \"$HOME/.claude/hooks/adoption-log.mjs\""
31
+ "command": "node \"{{HOOKS_DIR}}/adoption-log.mjs\""
32
32
  }
33
33
  ]
34
34
  }
@@ -39,7 +39,7 @@
39
39
  "hooks": [
40
40
  {
41
41
  "type": "command",
42
- "command": "node \"$HOME/.claude/hooks/adoption-log.mjs\""
42
+ "command": "node \"{{HOOKS_DIR}}/adoption-log.mjs\""
43
43
  }
44
44
  ]
45
45
  }