@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.
- package/.claude-plugin/marketplace.json +2 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +92 -0
- package/README.md +37 -9
- package/bin/tune-context.mjs +2 -2
- package/cli/generate.mjs +42 -0
- package/cli/verify.mjs +74 -3
- package/eval/adoption-report.mjs +121 -33
- package/hooks/adoption-log.mjs +2 -2
- package/hooks/config-dir.mjs +12 -0
- package/hooks/pre-compact-snapshot.mjs +2 -2
- package/package.json +4 -2
- package/skills/tune-context/SKILL.md +39 -7
- package/skills/tune-context/templates/settings.json.tmpl +4 -4
|
@@ -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
|
|
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
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
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`
|
|
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/`
|
|
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
|
|
package/bin/tune-context.mjs
CHANGED
|
@@ -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 =
|
|
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 {
|
|
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
|
|
25
|
-
|
|
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))];
|
package/eval/adoption-report.mjs
CHANGED
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
// measures *behavior* (a proxy), not dollars. OTEL is the opt-in complement.
|
|
12
12
|
// • caveman can't be measured: it's an output style, not a tool call.
|
|
13
13
|
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
-
import { homedir } from 'node:os';
|
|
15
14
|
import { join } from 'node:path';
|
|
16
15
|
import { parseArgs } from 'node:util';
|
|
16
|
+
import { claudeBaseDir } from '../hooks/config-dir.mjs';
|
|
17
17
|
|
|
18
18
|
const DOCTRINE_SKILLS = new Set([
|
|
19
19
|
'token-efficiency',
|
|
@@ -22,8 +22,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(
|
|
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:
|
|
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,
|
|
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(); //
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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 —
|
|
142
|
-
L.push(` semantic (RAG):
|
|
143
|
-
L.push(`
|
|
144
|
-
L.push(`
|
|
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
|
|
147
|
-
(a.ragShare === null ? ' (no
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
183
|
-
? ' ✓
|
|
184
|
-
: '
|
|
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:');
|
package/hooks/adoption-log.mjs
CHANGED
|
@@ -28,8 +28,8 @@ import {
|
|
|
28
28
|
renameSync,
|
|
29
29
|
statSync,
|
|
30
30
|
} from 'node:fs';
|
|
31
|
-
import { homedir } from 'node:os';
|
|
32
31
|
import { dirname, join } from 'node:path';
|
|
32
|
+
import { claudeBaseDir } from './config-dir.mjs';
|
|
33
33
|
|
|
34
34
|
const MAX_LOG_BYTES = 5 * 1024 * 1024; // rotate to <path>.1 past this (bounded ~2×)
|
|
35
35
|
|
|
@@ -42,7 +42,7 @@ function readStdin() {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
export function logFilePath() {
|
|
45
|
-
return join(
|
|
45
|
+
return join(claudeBaseDir(), 'tune-context', 'adoption.jsonl');
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
// Detect a text-search shell command (grep/rg/find/ag/ack), the grep-side of the
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Shared base-dir resolution for hook STATE paths (snapshots, adoption log).
|
|
2
|
+
// Mirrors claudeDirs() in cli/detect.mjs — same rule, but must live here rather
|
|
3
|
+
// than be imported from cli/: syncDoctrine only copies hooks/ into
|
|
4
|
+
// <claudeDir>/hooks/, so an installed hook has no access to cli/ at runtime.
|
|
5
|
+
// eval/adoption-report.mjs imports this too, via a relative path (eval/ is
|
|
6
|
+
// never installed, so that path is safe only in-repo).
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
export function claudeBaseDir() {
|
|
11
|
+
return process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
|
|
12
|
+
}
|
|
@@ -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(
|
|
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
|
|
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)
|
|
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
|
|
101
|
-
`additionalContext`. Filesystem/git facts only — not decisions/open
|
|
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
|
|
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 \"
|
|
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 \"
|
|
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 \"
|
|
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 \"
|
|
42
|
+
"command": "node \"{{HOOKS_DIR}}/adoption-log.mjs\""
|
|
43
43
|
}
|
|
44
44
|
]
|
|
45
45
|
}
|