@bglocation/tune-context 1.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +160 -0
- package/README.md +92 -7
- package/bin/tune-context +11 -0
- package/bin/tune-context.mjs +109 -15
- package/cli/detect.mjs +92 -16
- package/cli/generate.mjs +6 -0
- package/cli/sync-doctrine.mjs +11 -2
- package/cli/verify.mjs +50 -5
- package/eval/adoption-report.mjs +91 -1
- package/hooks/context-reminder.mjs +212 -0
- package/hooks/hooks.json +11 -0
- package/hooks/pre-compact-snapshot.mjs +68 -6
- package/package.json +1 -1
- package/skills/token-efficiency/SKILL.md +3 -1
- package/skills/tune-context/SKILL.md +58 -15
- package/skills/tune-context/templates/settings.json.tmpl +11 -10
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"name": "tune-context",
|
|
11
11
|
"source": "./",
|
|
12
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.",
|
|
13
|
-
"version": "
|
|
13
|
+
"version": "2.0.0",
|
|
14
14
|
"author": {
|
|
15
15
|
"name": "Szymon Walczak",
|
|
16
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": "
|
|
5
|
+
"version": "2.0.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Szymon Walczak",
|
|
8
8
|
"url": "https://bglocation.dev"
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,166 @@ All notable changes to `@bglocation/tune-context` are documented here.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [2.0.0] — 2026-07-25
|
|
9
|
+
|
|
10
|
+
Three hardening epics' worth of work plus the first new feature since 1.0: a
|
|
11
|
+
reminder that measures what a session actually costs. **The major bump is for
|
|
12
|
+
one breaking change** — `tune-context` with no subcommand no longer runs `init`
|
|
13
|
+
(see below). Everything else is additive; see "Upgrading from 1.1.0" at the end.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **Context-size reminder** (`hooks/context-reminder.mjs`, `UserPromptSubmit`).
|
|
18
|
+
Claude can't see its own context usage — nothing in a turn reports how many
|
|
19
|
+
tokens are being re-sent, and Claude Code has no timer or threshold event — so
|
|
20
|
+
this hook reads the tail of the session transcript, sums the tokens actually
|
|
21
|
+
re-sent, and once that passes a PO-frozen **120k** injects one short note
|
|
22
|
+
suggesting `/compact`. Fires **once per crossing**, re-arming only after a
|
|
23
|
+
compaction-sized drop: the note costs tokens on the turn it appears in, so
|
|
24
|
+
nagging every prompt would spend exactly what the tool saves (measured on a
|
|
25
|
+
12.6h session: 4 reminders total). Reminds on *size*, not "phase boundary" —
|
|
26
|
+
size is measurable, a phase boundary is a judgment, so the reminder hands the
|
|
27
|
+
timing call to the model and tells it to stay quiet mid-task. Override with
|
|
28
|
+
`TUNE_CONTEXT_REMIND_TOKENS`; remove the `UserPromptSubmit` entry to disable.
|
|
29
|
+
(TASK-079)
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- **Plugin-shaped hook commands no longer misread as broken.** `cli/verify.mjs`
|
|
34
|
+
treated any registered command it couldn't resolve to an installed file as
|
|
35
|
+
stray or dead — including a plugin's own `${CLAUDE_PLUGIN_ROOT}`-based
|
|
36
|
+
registration, which it was never meant to check. Now reported as its own
|
|
37
|
+
always-ok informational line instead. (EPIC-008 / TASK-072)
|
|
38
|
+
- **Adoption report no longer prints a false churn verdict over doubled
|
|
39
|
+
events.** Running the plugin and `tune-context init` together on one repo
|
|
40
|
+
registers each hook twice, so every event logs twice — this previously
|
|
41
|
+
surfaced as spurious "elevated re-read churn." The report now detects a
|
|
42
|
+
near-duplicate signature (records identical on session/tool/lever/detail
|
|
43
|
+
within a measured 100ms window, at ≥10% of assessable records) and withholds
|
|
44
|
+
the re-read-churn verdict instead of printing a number the doubling would
|
|
45
|
+
have faked. Thresholds are frozen from two independent real logs, not
|
|
46
|
+
guessed — a naively "safe" 1000ms window was shown to swallow real churn
|
|
47
|
+
instead. (EPIC-008 / TASK-072)
|
|
48
|
+
- **Generated commands now match the detected package manager exactly.**
|
|
49
|
+
`CLAUDE.md` and `permissions.allow` used to assume npm-style `run` commands
|
|
50
|
+
regardless of lockfile. Now derived from one `runCmd(packageManager, script)`
|
|
51
|
+
table: npm/pnpm use `npm run build` / `pnpm run build` (`test` keeps its
|
|
52
|
+
shorthand), yarn uses the bare short form for all three — `yarn build`,
|
|
53
|
+
`yarn test`, `yarn lint` (PO decision). (EPIC-008 / TASK-073)
|
|
54
|
+
- **`disabledMcpjsonServers` is now honoured.** A project `.mcp.json` server a
|
|
55
|
+
user rejected via `disabledMcpjsonServers` (in any settings file) no longer
|
|
56
|
+
gets a `permissions.allow` rule or a `CLAUDE.md` mention — previously
|
|
57
|
+
`tune-context init` wired it in regardless. Three neighbouring keys
|
|
58
|
+
(`enabledMcpjsonServers`, `allowedMcpServers`, `deniedMcpServers`) are
|
|
59
|
+
deliberately not treated as equivalent, verified against docs rather than
|
|
60
|
+
guessed — each would need introspection this CLI doesn't have.
|
|
61
|
+
(EPIC-008 / TASK-075)
|
|
62
|
+
- **PreCompact snapshot files could collide across projects.** `state/` keyed
|
|
63
|
+
snapshots by a human-readable slug of `cwd` alone, which is not injective —
|
|
64
|
+
`/home/a/b` and `/home/a-b` (also `.`/`_` variants) slugged to the same
|
|
65
|
+
filename, so `SessionStart(compact)` could reinject one project's git state
|
|
66
|
+
into an unrelated session. A short hash of the real `cwd` is now appended to
|
|
67
|
+
the filename. **Breaking for `state/` only**: snapshots written under the old
|
|
68
|
+
filename shape are orphaned (harmless — a snapshot is only ever valid for the
|
|
69
|
+
one compaction right after it) rather than reused; the new retention policy
|
|
70
|
+
below cleans them up over time. (EPIC-008 / TASK-076)
|
|
71
|
+
- **`pre-compact-snapshot.mjs` could exit non-zero.** Brought to parity with
|
|
72
|
+
`adoption-log.mjs`: an unwritable state directory (read-only volume,
|
|
73
|
+
misdirected `CLAUDE_CONFIG_DIR`) now fails the snapshot silently instead of
|
|
74
|
+
surfacing a nonzero exit from a hook at the exact moment a session is
|
|
75
|
+
compacting under context pressure. (EPIC-008 / TASK-076)
|
|
76
|
+
- **Manual hook-install instructions now produce a working hook.** README
|
|
77
|
+
pointed at the raw `settings.json.tmpl`, which still has an unsubstituted
|
|
78
|
+
`{{HOOKS_DIR}}` placeholder and no instruction to replace it — followed
|
|
79
|
+
literally, the hook command was `node "{{HOOKS_DIR}}/adoption-log.mjs"` and
|
|
80
|
+
silently never ran. Replaced with a ready-to-paste `settings.json` snippet.
|
|
81
|
+
(EPIC-008 / TASK-078)
|
|
82
|
+
- **Plugin-only installs now get a working `tune-context` command.** Claude
|
|
83
|
+
Code exposes `bin/` files under their literal filename — confirmed against
|
|
84
|
+
current docs, no extension-stripping — so `bin/tune-context.mjs` only ever
|
|
85
|
+
produced a bare `tune-context.mjs`. A plugin-only install (no
|
|
86
|
+
`npm install -g`) got "command not found" for the command README documents.
|
|
87
|
+
Added an extensionless `bin/tune-context` alias so both channels give the
|
|
88
|
+
identical command. (EPIC-008 / TASK-078)
|
|
89
|
+
- **`tune-context --cwd` (no value) no longer crashes with a Node stacktrace.**
|
|
90
|
+
`resolve(undefined)` used to throw `ERR_INVALID_ARG_TYPE` straight to the
|
|
91
|
+
terminal; now reports "Missing value for `--cwd`" plus usage, on stderr,
|
|
92
|
+
exit 1. **Breaking:** an unrecognized *or missing* subcommand is now also
|
|
93
|
+
rejected the same way instead of silently running `init` — `init` is the
|
|
94
|
+
only accepted subcommand. README never documented calling this tool with no
|
|
95
|
+
arguments; every example already used explicit `init`. (EPIC-008 / TASK-078)
|
|
96
|
+
|
|
97
|
+
### Changed
|
|
98
|
+
|
|
99
|
+
- **One source of truth for `CLAUDE_CONFIG_DIR` resolution.** `cli/detect.mjs`'s
|
|
100
|
+
`claudeDirs()` now delegates the `CLAUDE_CONFIG_DIR || ~/.claude` rule to
|
|
101
|
+
`hooks/config-dir.mjs`'s `claudeBaseDir()` instead of re-implementing it —
|
|
102
|
+
the two could previously drift apart. No behavior change.
|
|
103
|
+
(EPIC-008 / TASK-074)
|
|
104
|
+
- **Removed a dead `permissions` block from the settings template.**
|
|
105
|
+
`settings.json.tmpl` carried a `permissions.allow` block nothing ever read —
|
|
106
|
+
the CLI merges its own, computed from the detected stack, straight into the
|
|
107
|
+
*project's* settings instead. PO-approved removal. (EPIC-008 / TASK-074)
|
|
108
|
+
- **`bin/tune-context.mjs`'s header states its real scope.** No longer reads
|
|
109
|
+
as a full twin of the `/tune-context` skill — names the two steps it
|
|
110
|
+
deliberately can't do (merging in a semantic-search MCP server, wiki-lever
|
|
111
|
+
wiring) instead of leaving the gap unstated. (EPIC-008 / TASK-075)
|
|
112
|
+
|
|
113
|
+
### Added
|
|
114
|
+
|
|
115
|
+
- **`state/` retention.** Snapshots untouched for 30 days are pruned on the
|
|
116
|
+
next PreCompact write anywhere on the machine — `state/` is one shared
|
|
117
|
+
directory across all projects, so any project's compaction sweeps it, not
|
|
118
|
+
only the project being written; the snapshot just written is never a prune
|
|
119
|
+
candidate. Previously `state/` was the only tune-context artifact with no
|
|
120
|
+
cleanup policy. (EPIC-008 / TASK-076)
|
|
121
|
+
- **`tune-context --version`** (also `-v`), printing the bare number so
|
|
122
|
+
`$(tune-context --version)` is directly comparable. Read from the shipped
|
|
123
|
+
`package.json` — the same file `verify:plugin` cross-checks against
|
|
124
|
+
`plugin.json` and `marketplace.json`, so the CLI can't report a version the
|
|
125
|
+
manifests disagree with.
|
|
126
|
+
- **README: "Keeping context lean while you work".** The tool configures a
|
|
127
|
+
lean setup, but shedding accumulated context stays a deliberate, recurring
|
|
128
|
+
act — `/compact` when a thread closes, `/clear` when switching tasks — and
|
|
129
|
+
the README never said so. Includes what the PreCompact/SessionStart pair
|
|
130
|
+
does and does not preserve across a compaction.
|
|
131
|
+
- **`token-efficiency` §6 recommends `eval/adoption-report.mjs`.** Previously
|
|
132
|
+
the section on measuring whether doctrine is actually used pointed only at
|
|
133
|
+
`rag-usage`, a different package's tool — silent about the one instrument
|
|
134
|
+
this repo ships for the same purpose. (EPIC-008 / TASK-078)
|
|
135
|
+
|
|
136
|
+
### Testing / packaging
|
|
137
|
+
|
|
138
|
+
- **Unit test harness for the CLI.** `npm test` now also exercises
|
|
139
|
+
`cli/managed-block.mjs`, `cli/generate.mjs`, `cli/verify.mjs` and
|
|
140
|
+
`cli/detect.mjs` directly — previously only reachable end-to-end through
|
|
141
|
+
`smoke:init` — plus a structural guard that fails when a `cli/` export has
|
|
142
|
+
nothing importing it. (EPIC-008 / TASK-077)
|
|
143
|
+
- **`verify:plugin` and `verify:pack` now guard `bin/` and
|
|
144
|
+
`eval/adoption-report.mjs`.** Both were tracked by git accidentally, not by
|
|
145
|
+
contract, despite README telling a plugin-only or npm install to run them —
|
|
146
|
+
a clone or tarball missing either would previously pass both checks anyway.
|
|
147
|
+
(EPIC-008 / TASK-078)
|
|
148
|
+
|
|
149
|
+
### Upgrading from 1.1.0
|
|
150
|
+
|
|
151
|
+
Re-running `tune-context init` over a 1.1.0 install is safe and was verified
|
|
152
|
+
end-to-end: hook entries are not duplicated, unrelated `settings.json` keys and
|
|
153
|
+
hand-written `CLAUDE.md` content are untouched, and your own
|
|
154
|
+
`permissions.allow` rules are kept.
|
|
155
|
+
|
|
156
|
+
Two leftovers are deliberate, because removing them would break "never
|
|
157
|
+
clobber" — tune-context cannot tell its own past output from a rule you added
|
|
158
|
+
on purpose:
|
|
159
|
+
|
|
160
|
+
- **pnpm/yarn projects**: the npm-style rules 1.1.0 wrote (`Bash(npm test:*)`
|
|
161
|
+
and friends) stay in `.claude/settings.json` alongside the correct new ones
|
|
162
|
+
(`Bash(pnpm test:*)`). Harmless — an unused allow rule grants nothing extra
|
|
163
|
+
for a command you never run — but safe to delete by hand.
|
|
164
|
+
- **PreCompact snapshots** under `<config dir>/tune-context/state/` written
|
|
165
|
+
before this release keep their old filenames and are ignored. The new
|
|
166
|
+
30-day retention deletes them on its own; no action needed.
|
|
167
|
+
|
|
8
168
|
## [1.1.0] — 2026-07-24
|
|
9
169
|
|
|
10
170
|
Two hardening epics land together: consistent `CLAUDE_CONFIG_DIR` support with the
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ tune-context/
|
|
|
15
15
|
├── .claude-plugin/plugin.json # Claude Code plugin manifest
|
|
16
16
|
├── skills/ # token-efficiency, caveman, cdd, phase-workflow, tune-context (+ templates/)
|
|
17
17
|
├── agents/ # explore-cheap, reviewer (model right-sizing)
|
|
18
|
-
├── hooks/ # PreCompact state-doc, SessionStart re-inject,
|
|
18
|
+
├── hooks/ # PreCompact state-doc, SessionStart re-inject, adoption log, context reminder + hooks.json
|
|
19
19
|
├── bin/ + cli/ # `tune-context init` — deterministic detect → generate → verify
|
|
20
20
|
├── eval/ # adoption-report.mjs (the rag-usage analog)
|
|
21
21
|
└── scripts/ # verify-pack, smoke-init, sync-tune-context (dev/maintenance)
|
|
@@ -70,8 +70,9 @@ then generates a lean project `CLAUDE.md`, wires the doctrine skills, and merges
|
|
|
70
70
|
hooks + permissions — idempotently, never clobbering hand-written content. This is
|
|
71
71
|
the full-fidelity path (it also handles wiki wiring and semantic-search detection).
|
|
72
72
|
|
|
73
|
-
**2. CLI `tune-context init`.** The deterministic
|
|
74
|
-
managed-block rules, same never-clobber guarantee
|
|
73
|
+
**2. CLI `tune-context init`.** The deterministic **subset** of the skill — same
|
|
74
|
+
managed-block rules, same never-clobber guarantee, no model in the loop. Good for
|
|
75
|
+
CI or scripted setup:
|
|
75
76
|
|
|
76
77
|
```bash
|
|
77
78
|
npx -p @bglocation/tune-context tune-context init # runs in the current repo
|
|
@@ -83,6 +84,15 @@ node bin/tune-context.mjs init --cwd /path/to/your/repo
|
|
|
83
84
|
It prints what it detected, what it synced/merged, and a verification report; a
|
|
84
85
|
second run is a no-op.
|
|
85
86
|
|
|
87
|
+
Two steps of the skill have **no CLI equivalent**, because each needs judgment a
|
|
88
|
+
deterministic tool cannot supply — run `/tune-context` if you want them:
|
|
89
|
+
|
|
90
|
+
| Step | Skill | CLI |
|
|
91
|
+
|---|---|---|
|
|
92
|
+
| Project `CLAUDE.md`, doctrine skills, hooks, permissions | ✅ | ✅ |
|
|
93
|
+
| Merge `.mcp.json` — add a semantic-search server if none was detected | ✅ | ❌ reads it, never writes it |
|
|
94
|
+
| Wiki lever — RAG segment vs its own MCP vs a plain pointer | ✅ | ❌ not detected, not wired |
|
|
95
|
+
|
|
86
96
|
**3. Plugin install.** Load the plugin so the skills, agents and hooks are available
|
|
87
97
|
in every session. The best UX is the **marketplace** — install once, get updates
|
|
88
98
|
with `/plugin marketplace update`:
|
|
@@ -129,6 +139,51 @@ Without the override the generated `settings.json` keeps the portable
|
|
|
129
139
|
machines). Under the override it's baked to the literal `/x/hooks/…` — where the
|
|
130
140
|
scripts actually land. Useful for shared accounts, containers, and test isolation.
|
|
131
141
|
|
|
142
|
+
## Keeping context lean while you work
|
|
143
|
+
|
|
144
|
+
Setup is the cheap half. The recurring half is **shedding context you no longer
|
|
145
|
+
need** — and that stays a manual, deliberate act, because only you know when a
|
|
146
|
+
piece of work is actually finished.
|
|
147
|
+
|
|
148
|
+
Every turn re-sends the whole conversation as input. A session that has been
|
|
149
|
+
running for hours pays for its early exploration on *every* later request, long
|
|
150
|
+
after that detail stopped being useful. So:
|
|
151
|
+
|
|
152
|
+
| When | Do | Why |
|
|
153
|
+
|---|---|---|
|
|
154
|
+
| A work thread closes (feature done, bug fixed) | `/compact` | Keeps the conclusions, drops the raw tool output that led to them |
|
|
155
|
+
| Switching to unrelated work | `/clear` | Nothing from the old task is worth carrying — `/compact` would still keep a summary of it |
|
|
156
|
+
| Long session, no natural break yet | `/compact` anyway | Waiting for auto-compact means paying full price right up to the limit |
|
|
157
|
+
|
|
158
|
+
tune-context softens the cost of compacting rather than replacing it: its
|
|
159
|
+
`PreCompact` hook snapshots your branch, dirty files and recent commits, and the
|
|
160
|
+
`SessionStart(compact)` hook injects that back afterwards — so the mechanical
|
|
161
|
+
state survives even though the transcript doesn't. Judgment calls and open
|
|
162
|
+
threads do **not** survive; write those down before compacting if they matter.
|
|
163
|
+
|
|
164
|
+
### The reminder is automatic
|
|
165
|
+
|
|
166
|
+
Claude cannot see its own context usage — nothing in a turn tells the model how
|
|
167
|
+
many tokens it is re-sending, and Claude Code has no timer or context-threshold
|
|
168
|
+
hook event. So `context-reminder.mjs` (registered under `UserPromptSubmit`)
|
|
169
|
+
supplies the missing measurement: it reads the tail of the session transcript,
|
|
170
|
+
sums the tokens actually re-sent, and once that passes **120,000** it injects a
|
|
171
|
+
single short note suggesting `/compact`.
|
|
172
|
+
|
|
173
|
+
- **Once per crossing, not per prompt.** The note itself costs tokens on the turn
|
|
174
|
+
it appears in, so it re-arms only after a compaction-sized drop. Measured on a
|
|
175
|
+
12.6-hour session: 4 reminders total.
|
|
176
|
+
- **The model picks the moment.** The reminder tells Claude to stay quiet
|
|
177
|
+
mid-task and raise it when the thread closes — the hook can measure size, but
|
|
178
|
+
only the model can see whether you're mid-refactor.
|
|
179
|
+
- **Raise it for a large context window:** `export TUNE_CONTEXT_REMIND_TOKENS=400000`.
|
|
180
|
+
120k is ~60% of a standard 200k window; the transcript doesn't record the window
|
|
181
|
+
size, so this can't be auto-scaled.
|
|
182
|
+
- **Turn it off:** delete the `UserPromptSubmit` entry from `settings.json`.
|
|
183
|
+
|
|
184
|
+
Depth lives in the `phase-workflow` and `token-efficiency` skills (both installed
|
|
185
|
+
by `tune-context init`), not here.
|
|
186
|
+
|
|
132
187
|
## Measuring adoption (does the doctrine actually get used?)
|
|
133
188
|
`hooks/adoption-log.mjs` logs one JSONL line per event to
|
|
134
189
|
`~/.claude/tune-context/adoption.jsonl` by default (under `CLAUDE_CONFIG_DIR`, the
|
|
@@ -142,6 +197,12 @@ It's registered under two Claude Code hook events —
|
|
|
142
197
|
`PostToolUse` (every tool call) and `SubagentStart` (subagent spawns, which aren't
|
|
143
198
|
regular tools).
|
|
144
199
|
|
|
200
|
+
**Using both the plugin and `tune-context init` (CLI) on the same repo** registers
|
|
201
|
+
the hook twice — once per channel — so every event logs twice. This is harmless
|
|
202
|
+
(same script, same log, just doubled counts) and the report detects it: past a
|
|
203
|
+
measured share of near-duplicate pairs it withholds the re-read-churn verdict
|
|
204
|
+
instead of printing a number the doubling would have faked.
|
|
205
|
+
|
|
145
206
|
### Turn it on
|
|
146
207
|
|
|
147
208
|
1. **Install the hook.** Either run **`/tune-context`** in your repo (it copies the
|
|
@@ -149,10 +210,34 @@ regular tools).
|
|
|
149
210
|
for you), or do it by hand:
|
|
150
211
|
- copy `hooks/adoption-log.mjs` **and** `hooks/config-dir.mjs` (it imports the
|
|
151
212
|
latter for the log path) → `~/.claude/hooks/` (needs Node ≥ 18 on `PATH`);
|
|
152
|
-
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
213
|
+
- merge this into your `settings.json` — `~/.claude/settings.json` for every
|
|
214
|
+
project, or a repo's `.claude/settings.json` for just that one (if the file
|
|
215
|
+
doesn't exist yet, this can be the whole thing):
|
|
216
|
+
```json
|
|
217
|
+
{
|
|
218
|
+
"hooks": {
|
|
219
|
+
"PostToolUse": [
|
|
220
|
+
{
|
|
221
|
+
"matcher": "*",
|
|
222
|
+
"hooks": [
|
|
223
|
+
{ "type": "command", "command": "node \"$HOME/.claude/hooks/adoption-log.mjs\"" }
|
|
224
|
+
]
|
|
225
|
+
}
|
|
226
|
+
],
|
|
227
|
+
"SubagentStart": [
|
|
228
|
+
{
|
|
229
|
+
"matcher": "*",
|
|
230
|
+
"hooks": [
|
|
231
|
+
{ "type": "command", "command": "node \"$HOME/.claude/hooks/adoption-log.mjs\"" }
|
|
232
|
+
]
|
|
233
|
+
}
|
|
234
|
+
]
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
Using `CLAUDE_CONFIG_DIR`? Replace `$HOME/.claude/hooks` with the literal
|
|
239
|
+
override path instead — `$HOME` is not expanded there (see
|
|
240
|
+
[`CLAUDE_CONFIG_DIR`](#claude_config_dir) above).
|
|
156
241
|
|
|
157
242
|
2. **No reload needed.** Claude Code watches the settings files, so the new hooks
|
|
158
243
|
go live within a few seconds of saving — **you do not need to reload VSCode,
|
package/bin/tune-context
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Extensionless alias so the plugin channel's bin/ PATH entry gives the same
|
|
3
|
+
// `tune-context` command the npm channel already provides via package.json's
|
|
4
|
+
// `bin` field. Claude Code exposes bin/ files under their literal filename —
|
|
5
|
+
// no extension-stripping — so without this file, a plugin-only install
|
|
6
|
+
// (no `npm install -g`) only offers `tune-context.mjs` (TASK-078). Real logic
|
|
7
|
+
// stays in tune-context.mjs; this just re-executes it against the same
|
|
8
|
+
// process.argv.
|
|
9
|
+
import { main } from './tune-context.mjs';
|
|
10
|
+
|
|
11
|
+
main();
|
package/bin/tune-context.mjs
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// tune-context init — deterministic detect -> generate -> verify.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
//
|
|
4
|
+
// This is the deterministic SUBSET of skills/tune-context/SKILL.md, not a full
|
|
5
|
+
// twin of it. What it shares: the same managed-block rules and the same "never
|
|
6
|
+
// clobber" guarantee. What it deliberately omits, because each needs judgment
|
|
7
|
+
// a no-model-in-the-loop CLI cannot have (TASK-075):
|
|
8
|
+
//
|
|
9
|
+
// • SKILL.md §2 step 4 — merging `.mcp.json` to add a semantic-search server
|
|
10
|
+
// when none was detected. Choosing *which* server to add for this repo is a
|
|
11
|
+
// judgment call; the CLI reads `.mcp.json` but never writes it.
|
|
12
|
+
// • SKILL.md §2 step 7 — wiring the wiki lever (RAG segment vs its own MCP vs
|
|
13
|
+
// a plain pointer). That choice depends on where the docs actually live and
|
|
14
|
+
// how they are hosted, which the CLI does not attempt to detect.
|
|
15
|
+
//
|
|
16
|
+
// "No model in the loop" is the feature here, not a gap — but the gap has to be
|
|
17
|
+
// stated, or a CLI user cannot know what they did not get. Run the skill
|
|
18
|
+
// (/tune-context) for the full pass.
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
5
20
|
import { dirname, join, resolve } from 'node:path';
|
|
6
21
|
import { fileURLToPath } from 'node:url';
|
|
7
|
-
import { claudeDirs, detectExistingConfig, detectMcpServers, detectStack } from '../cli/detect.mjs';
|
|
22
|
+
import { claudeDirs, detectExistingConfig, detectMcpServers, detectRejectedMcpServers, detectStack } from '../cli/detect.mjs';
|
|
8
23
|
import {
|
|
9
24
|
buildPermissionsAllow,
|
|
10
25
|
generateProjectClaudeMd,
|
|
@@ -21,26 +36,83 @@ const HOOK_EVENTS = [
|
|
|
21
36
|
{ event: 'SessionStart', scriptName: 'session-start-reinject.mjs' },
|
|
22
37
|
{ event: 'PostToolUse', scriptName: 'adoption-log.mjs' },
|
|
23
38
|
{ event: 'SubagentStart', scriptName: 'adoption-log.mjs' },
|
|
39
|
+
{ event: 'UserPromptSubmit', scriptName: 'context-reminder.mjs' },
|
|
24
40
|
];
|
|
25
41
|
|
|
42
|
+
const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Version straight from the shipped package.json — one source of truth, the
|
|
46
|
+
* same file verify:plugin cross-checks against plugin.json/marketplace.json,
|
|
47
|
+
* so `--version` can never drift from what the manifests claim. Both channels
|
|
48
|
+
* ship it: npm always includes package.json in the tarball, and the plugin
|
|
49
|
+
* channel is a git clone of this repo (guarded in verify-plugin's REQUIRE).
|
|
50
|
+
*/
|
|
51
|
+
export function readVersion() {
|
|
52
|
+
return JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8')).version;
|
|
53
|
+
}
|
|
54
|
+
|
|
26
55
|
function usage() {
|
|
27
|
-
|
|
56
|
+
return `tune-context init — configure this repo for token-efficient Claude Code use.
|
|
28
57
|
|
|
29
58
|
Usage:
|
|
30
59
|
tune-context init [--cwd <dir>]
|
|
31
60
|
tune-context --help
|
|
32
|
-
|
|
61
|
+
tune-context --version
|
|
62
|
+
`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Pure so it is unit-testable without spawning a process. `init` is the ONLY
|
|
66
|
+
// accepted command — no bare-args shortcut (PO decision 2026-07-25, TASK-078):
|
|
67
|
+
// README never once documents calling this without it, and accepting anything
|
|
68
|
+
// un-checked is exactly how a subcommand typo used to silently run init
|
|
69
|
+
// against the wrong repo instead of failing loud.
|
|
70
|
+
export function parseArgs(argv) {
|
|
71
|
+
if (argv.includes('--help') || argv.includes('-h')) return { mode: 'help' };
|
|
72
|
+
// Before the command check, like --help: `--version` is a query about the
|
|
73
|
+
// tool, not an operation on a repo, so it must not require a subcommand.
|
|
74
|
+
if (argv.includes('--version') || argv.includes('-v')) return { mode: 'version' };
|
|
75
|
+
const [command, ...rest] = argv;
|
|
76
|
+
if (command !== 'init') {
|
|
77
|
+
return {
|
|
78
|
+
mode: 'error',
|
|
79
|
+
message: command === undefined ? 'Missing command: init' : `Unknown command: ${command}`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const cwdFlagIdx = rest.indexOf('--cwd');
|
|
83
|
+
if (cwdFlagIdx === -1) return { mode: 'init', cwd: process.cwd() };
|
|
84
|
+
const value = rest[cwdFlagIdx + 1];
|
|
85
|
+
// Undefined (last arg) or another flag (e.g. `--cwd --foo`) both mean "no
|
|
86
|
+
// value was actually given" — resolve(undefined) used to throw a raw
|
|
87
|
+
// ERR_INVALID_ARG_TYPE with a Node stacktrace instead of a usage message.
|
|
88
|
+
if (value === undefined || value.startsWith('-')) {
|
|
89
|
+
return { mode: 'error', message: 'Missing value for --cwd' };
|
|
90
|
+
}
|
|
91
|
+
return { mode: 'init', cwd: resolve(value) };
|
|
33
92
|
}
|
|
34
93
|
|
|
35
94
|
function runInit(cwd) {
|
|
36
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
37
|
-
const pkgRoot = resolve(here, '..');
|
|
38
95
|
const templatesDir = join(pkgRoot, 'skills', 'tune-context', 'templates');
|
|
39
96
|
const { claudeDir } = claudeDirs();
|
|
40
97
|
|
|
41
98
|
const stack = detectStack(cwd);
|
|
42
99
|
const existing = detectExistingConfig(cwd);
|
|
43
|
-
|
|
100
|
+
|
|
101
|
+
// A server the user rejected out of .mcp.json must not get an
|
|
102
|
+
// `mcp__<server>__*` permission rule, nor a CLAUDE.md line advertising a
|
|
103
|
+
// lever this session does not have (TASK-075). Rejection applies ONLY to
|
|
104
|
+
// project-scope servers — see detectRejectedMcpServers for why user- and
|
|
105
|
+
// local-scope servers are out of its reach.
|
|
106
|
+
const allMcpServers = detectMcpServers(cwd);
|
|
107
|
+
const rejectedNames = detectRejectedMcpServers([
|
|
108
|
+
existing.userSettingsJson,
|
|
109
|
+
existing.projectSettingsJson,
|
|
110
|
+
existing.projectSettingsLocalJson,
|
|
111
|
+
]);
|
|
112
|
+
const isRejected = (s) => s.scope === 'project' && rejectedNames.has(s.name);
|
|
113
|
+
const mcpServers = allMcpServers.filter((s) => !isRejected(s));
|
|
114
|
+
const rejectedServers = allMcpServers.filter(isRejected);
|
|
115
|
+
|
|
44
116
|
const detection = { stack, mcpServers };
|
|
45
117
|
|
|
46
118
|
const synced = installDoctrine(pkgRoot, claudeDir);
|
|
@@ -71,6 +143,10 @@ function runInit(cwd) {
|
|
|
71
143
|
console.log(`Hooks merged into ${existing.userSettingsJson}: ${hooksAdded.length ? hooksAdded.join(', ') : 'already present'}`);
|
|
72
144
|
console.log(`Permissions merged into ${existing.projectSettingsJson}: ${permissionsAdded.length ? permissionsAdded.join(', ') : 'already present'}`);
|
|
73
145
|
console.log(`MCP servers detected: ${mcpServers.length ? mcpServers.map((s) => `${s.name} (${s.scope}, ${s.classification})`).join(', ') : 'none'}`);
|
|
146
|
+
// Surfaced, never silent: skipping a server is a decision the user should see.
|
|
147
|
+
if (rejectedServers.length) {
|
|
148
|
+
console.log(` skipped (rejected via disabledMcpjsonServers): ${rejectedServers.map((s) => s.name).join(', ')} — no permission rule, no CLAUDE.md line`);
|
|
149
|
+
}
|
|
74
150
|
|
|
75
151
|
console.log(`\nVerify: ${result.ok ? 'OK' : 'FAILED'}`);
|
|
76
152
|
for (const check of result.checks) {
|
|
@@ -79,11 +155,29 @@ function runInit(cwd) {
|
|
|
79
155
|
if (!result.ok) process.exitCode = 1;
|
|
80
156
|
}
|
|
81
157
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
158
|
+
export function main() {
|
|
159
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
160
|
+
if (parsed.mode === 'help') {
|
|
161
|
+
console.log(usage());
|
|
162
|
+
} else if (parsed.mode === 'version') {
|
|
163
|
+
// Bare number, no prefix or banner — this is what a script greps.
|
|
164
|
+
console.log(readVersion());
|
|
165
|
+
} else if (parsed.mode === 'error') {
|
|
166
|
+
// Usage on stderr + exit 1, never a stacktrace, and — because this whole
|
|
167
|
+
// branch returns before runInit() is ever called — no config file is
|
|
168
|
+
// touched on an invalid invocation (TASK-078).
|
|
169
|
+
process.stderr.write(`${parsed.message}\n\n${usage()}`);
|
|
170
|
+
process.exitCode = 1;
|
|
171
|
+
} else {
|
|
172
|
+
runInit(parsed.cwd);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Guarded so importing this file for parseArgs/main (tests, or the
|
|
177
|
+
// extensionless bin/tune-context wrapper) never runs the CLI as a side
|
|
178
|
+
// effect of the import — only direct invocation of THIS file does. The
|
|
179
|
+
// wrapper has a different argv[1] (its own path), so it calls main()
|
|
180
|
+
// explicitly instead of relying on this check.
|
|
181
|
+
if (process.argv[1] && process.argv[1].endsWith('tune-context.mjs')) {
|
|
182
|
+
main();
|
|
89
183
|
}
|
package/cli/detect.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { existsSync, readFileSync } from 'node:fs';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
|
+
import { claudeBaseDir } from '../hooks/config-dir.mjs';
|
|
7
8
|
|
|
8
9
|
function readJson(path) {
|
|
9
10
|
try {
|
|
@@ -13,11 +14,46 @@ function readJson(path) {
|
|
|
13
14
|
}
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
// The actual command a user (and permissions.allow) would type for a
|
|
18
|
+
// package-manager script — single source of truth, consumed by detectStack
|
|
19
|
+
// below so a detected package manager can never be silently ignored when
|
|
20
|
+
// building buildCmd/testCmd/lintCmd (TASK-073).
|
|
21
|
+
//
|
|
22
|
+
// npm/pnpm: `test` has a built-in shorthand (no `run`); build/lint don't.
|
|
23
|
+
// yarn: short form for all three — PO decision 2026-07-25, TASK-073. Yarn
|
|
24
|
+
// doesn't require `run` for scripts that don't collide with a built-in
|
|
25
|
+
// command, and this is the form a user actually types, which is what has to
|
|
26
|
+
// match the permissions.allow rule.
|
|
27
|
+
const RUN_COMMANDS = {
|
|
28
|
+
npm: { build: 'npm run build', test: 'npm test', lint: 'npm run lint' },
|
|
29
|
+
pnpm: { build: 'pnpm run build', test: 'pnpm test', lint: 'pnpm run lint' },
|
|
30
|
+
yarn: { build: 'yarn build', test: 'yarn test', lint: 'yarn lint' },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** @returns {string|null} the command for `script` under `packageManager`, or null if either is unrecognized. */
|
|
34
|
+
export function runCmd(packageManager, script) {
|
|
35
|
+
return RUN_COMMANDS[packageManager]?.[script] ?? null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the base Claude config directory, honoring CLAUDE_CONFIG_DIR, plus
|
|
40
|
+
* the one piece of CLI-specific knowledge on top: `.claude.json`'s path.
|
|
41
|
+
* Thin wrapper — the actual `CLAUDE_CONFIG_DIR || ~/.claude` rule lives
|
|
42
|
+
* solely in claudeBaseDir() (hooks/config-dir.mjs), which also has to be
|
|
43
|
+
* reachable from installed hook scripts (syncDoctrine never copies cli/, so
|
|
44
|
+
* the rule can't live here and be imported the other way — TASK-074).
|
|
45
|
+
*
|
|
46
|
+
* claudeJsonPath is NOT simply `join(claudeDir, '.claude.json')`: under the
|
|
47
|
+
* default (no override), `.claude.json` sits next to `.claude/` as a
|
|
48
|
+
* *sibling* under $HOME, not inside it. An override redirects the whole
|
|
49
|
+
* config surface, `.claude.json` included, so there it lives *inside* the
|
|
50
|
+
* override dir instead. This asymmetry is existing, load-bearing behavior
|
|
51
|
+
* (predates this refactor) — preserved exactly, not something to "fix" here.
|
|
52
|
+
*/
|
|
17
53
|
export function claudeDirs() {
|
|
18
54
|
const override = process.env.CLAUDE_CONFIG_DIR;
|
|
19
55
|
return {
|
|
20
|
-
claudeDir:
|
|
56
|
+
claudeDir: claudeBaseDir(),
|
|
21
57
|
claudeJsonPath: override ? join(override, '.claude.json') : join(homedir(), '.claude.json'),
|
|
22
58
|
};
|
|
23
59
|
}
|
|
@@ -31,17 +67,14 @@ export function detectStack(cwd) {
|
|
|
31
67
|
const monorepo = Boolean(
|
|
32
68
|
pkg.workspaces || existsSync(join(cwd, 'pnpm-workspace.yaml')) || existsSync(join(cwd, 'turbo.json')) || existsSync(join(cwd, 'nx.json')),
|
|
33
69
|
);
|
|
70
|
+
const packageManager = existsSync(join(cwd, 'pnpm-lock.yaml')) ? 'pnpm' : existsSync(join(cwd, 'yarn.lock')) ? 'yarn' : 'npm';
|
|
34
71
|
return {
|
|
35
72
|
ecosystem: 'node',
|
|
36
73
|
name: pkg.name || null,
|
|
37
|
-
packageManager
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
: 'npm',
|
|
42
|
-
buildCmd: scripts.build ? 'npm run build' : null,
|
|
43
|
-
testCmd: scripts.test ? 'npm test' : null,
|
|
44
|
-
lintCmd: scripts.lint ? 'npm run lint' : null,
|
|
74
|
+
packageManager,
|
|
75
|
+
buildCmd: scripts.build ? runCmd(packageManager, 'build') : null,
|
|
76
|
+
testCmd: scripts.test ? runCmd(packageManager, 'test') : null,
|
|
77
|
+
lintCmd: scripts.lint ? runCmd(packageManager, 'lint') : null,
|
|
45
78
|
monorepo,
|
|
46
79
|
};
|
|
47
80
|
}
|
|
@@ -65,6 +98,11 @@ export function detectExistingConfig(cwd) {
|
|
|
65
98
|
userClaudeMd: existsSync(join(claudeDir, 'CLAUDE.md')) ? join(claudeDir, 'CLAUDE.md') : null,
|
|
66
99
|
projectMcpJson: existsSync(join(cwd, '.mcp.json')) ? join(cwd, '.mcp.json') : null,
|
|
67
100
|
projectSettingsJson: join(cwd, '.claude', 'settings.json'),
|
|
101
|
+
// Untracked per-developer overrides. Never written to — read only, because
|
|
102
|
+
// `disabledMcpjsonServers` counts from ANY settings file (see
|
|
103
|
+
// detectRejectedMcpServers) and this is where the docs say per-developer
|
|
104
|
+
// .mcp.json approvals and rejections usually land.
|
|
105
|
+
projectSettingsLocalJson: join(cwd, '.claude', 'settings.local.json'),
|
|
68
106
|
userSettingsJson: join(claudeDir, 'settings.json'),
|
|
69
107
|
};
|
|
70
108
|
}
|
|
@@ -105,10 +143,48 @@ export function detectMcpServers(cwd) {
|
|
|
105
143
|
return servers;
|
|
106
144
|
}
|
|
107
145
|
|
|
108
|
-
/**
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
146
|
+
/**
|
|
147
|
+
* Server names that settings files explicitly REJECT out of a project's
|
|
148
|
+
* `.mcp.json`. Deliberately narrow — one key, one meaning (TASK-075).
|
|
149
|
+
*
|
|
150
|
+
* Semantics verified against live Claude Code docs (code.claude.com/docs/en/settings
|
|
151
|
+
* and /docs/en/mcp, checked 2026-07-25), NOT inferred from the key names:
|
|
152
|
+
*
|
|
153
|
+
* `disabledMcpjsonServers` is a plain `string[]` of server names, applies
|
|
154
|
+
* ONLY to servers defined in a project `.mcp.json`, and genuinely means
|
|
155
|
+
* rejected — `claude mcp get <name>` renders such a server as
|
|
156
|
+
* "✘ Rejected (see disabledMcpjsonServers in settings)". Every settings file
|
|
157
|
+
* is read, not just one, because the docs are explicit: "A
|
|
158
|
+
* `disabledMcpjsonServers` entry in any settings file still rejects the server."
|
|
159
|
+
*
|
|
160
|
+
* Three neighbouring keys are deliberately NOT honoured here. Each would be a
|
|
161
|
+
* guess, which is the failure mode TASK-073 was about:
|
|
162
|
+
*
|
|
163
|
+
* • `enabledMcpjsonServers` — absence does NOT mean disabled. An unlisted
|
|
164
|
+
* `.mcp.json` server is "⏸ Pending approval": the user is asked and may
|
|
165
|
+
* well approve it. Treating unlisted as disabled would silently drop rules
|
|
166
|
+
* for servers that are about to start working.
|
|
167
|
+
* • `allowedMcpServers` / `deniedMcpServers` — arrays of OBJECTS
|
|
168
|
+
* (`{serverName}` | `{serverUrl}` | `{serverCommand}`), not names. They may
|
|
169
|
+
* appear in any settings file and merge from every source, but they are an
|
|
170
|
+
* organization-policy mechanism: enforcement rests on a managed tier
|
|
171
|
+
* (`managed-settings.json`, server-managed settings, MDM profile or
|
|
172
|
+
* registry) that this CLI does not read, and matching involves URL
|
|
173
|
+
* wildcards, `${VAR}` expansion and denylist-over-allowlist precedence.
|
|
174
|
+
* Honouring them by name alone would misjudge exactly the enterprise setups
|
|
175
|
+
* they exist for — the docs themselves warn that "a `serverName` entry, in
|
|
176
|
+
* either list, is not a security control."
|
|
177
|
+
*
|
|
178
|
+
* @param {string[]} settingsPaths - settings.json files to read, missing ones ignored
|
|
179
|
+
* @returns {Set<string>} rejected server names (empty set when nothing is rejected)
|
|
180
|
+
*/
|
|
181
|
+
export function detectRejectedMcpServers(settingsPaths) {
|
|
182
|
+
const rejected = new Set();
|
|
183
|
+
for (const path of settingsPaths) {
|
|
184
|
+
if (!path) continue;
|
|
185
|
+
const list = readJson(path)?.disabledMcpjsonServers;
|
|
186
|
+
if (!Array.isArray(list)) continue;
|
|
187
|
+
for (const name of list) if (typeof name === 'string' && name) rejected.add(name);
|
|
188
|
+
}
|
|
189
|
+
return rejected;
|
|
114
190
|
}
|