@bglocation/tune-context 1.1.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +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
+ "description": "Context-engineering tooling for Claude Code from bglocation \u2014 token-saving doctrine, configurators and semantic-search wiring.",
4
4
  "owner": {
5
5
  "name": "Szymon Walczak",
6
6
  "url": "https://bglocation.dev"
@@ -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": "1.1.0",
13
+ "version": "2.0.1",
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": "1.1.0",
5
+ "version": "2.0.1",
6
6
  "author": {
7
7
  "name": "Szymon Walczak",
8
8
  "url": "https://bglocation.dev"
package/CHANGELOG.md CHANGED
@@ -5,6 +5,206 @@ 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.1] — 2026-07-26
9
+
10
+ ### Fixed
11
+
12
+ - **The npm-installed CLI did nothing at all in 2.0.0.** `npm i -g` puts the
13
+ `bin` entry on PATH as a symlink named `tune-context`, with no extension, but
14
+ 2.0.0's entry-point guard tested `process.argv[1].endsWith('tune-context.mjs')`.
15
+ That is false for the symlink, so `main()` never ran: every command —
16
+ `init`, `--version`, `--help` — exited 0 without printing a byte or touching a
17
+ file. Now compares resolved real paths, which holds for the npm symlink, a
18
+ direct `.mjs` invocation, the extensionless `bin/tune-context` wrapper (still
19
+ exactly one run, not two) and `import` from a test.
20
+ - Only the npm channel was affected. Plugin installs call `bin/tune-context`,
21
+ which invokes `main()` itself, and the hooks are always registered by literal
22
+ path — both kept working.
23
+
24
+ ### Testing
25
+
26
+ - The suite spawned `.../bin/tune-context.mjs` everywhere, which is the one shape
27
+ no user ever runs; that is why 119 green tests said nothing about a completely
28
+ dead CLI. Added a test that installs the binary the way npm does — through an
29
+ extensionless symlink — and asserts a silent exit 0 is a failure.
30
+
31
+ ### Upgrading from 2.0.0 (npm channel)
32
+
33
+ Upgrading is not enough on its own. If you ran `tune-context init` under 2.0.0
34
+ it exited 0 and looked like it worked, but **nothing was installed** — so
35
+ `~/.claude/hooks/context-reminder.mjs` is missing and the `UserPromptSubmit`
36
+ hook was never registered. Re-run it after upgrading:
37
+
38
+ ```bash
39
+ npm i -g @bglocation/tune-context@2.0.1
40
+ tune-context --version # must print 2.0.1 — silence means the upgrade did not take
41
+ tune-context init
42
+ ```
43
+
44
+ The `init` is idempotent and additive: it never removes a hook or permission
45
+ entry, and never touches content outside the managed blocks. Plugin-only
46
+ installs were unaffected and need nothing.
47
+
48
+ ## [2.0.0] — 2026-07-25
49
+
50
+ Three hardening epics' worth of work plus the first new feature since 1.0: a
51
+ reminder that measures what a session actually costs. **The major bump is for
52
+ one breaking change** — `tune-context` with no subcommand no longer runs `init`
53
+ (see below). Everything else is additive; see "Upgrading from 1.1.0" at the end.
54
+
55
+ ### Added
56
+
57
+ - **Context-size reminder** (`hooks/context-reminder.mjs`, `UserPromptSubmit`).
58
+ Claude can't see its own context usage — nothing in a turn reports how many
59
+ tokens are being re-sent, and Claude Code has no timer or threshold event — so
60
+ this hook reads the tail of the session transcript, sums the tokens actually
61
+ re-sent, and once that passes a PO-frozen **120k** injects one short note
62
+ suggesting `/compact`. Fires **once per crossing**, re-arming only after a
63
+ compaction-sized drop: the note costs tokens on the turn it appears in, so
64
+ nagging every prompt would spend exactly what the tool saves (measured on a
65
+ 12.6h session: 4 reminders total). Reminds on *size*, not "phase boundary" —
66
+ size is measurable, a phase boundary is a judgment, so the reminder hands the
67
+ timing call to the model and tells it to stay quiet mid-task. Override with
68
+ `TUNE_CONTEXT_REMIND_TOKENS`; remove the `UserPromptSubmit` entry to disable.
69
+ (TASK-079)
70
+
71
+ ### Fixed
72
+
73
+ - **Plugin-shaped hook commands no longer misread as broken.** `cli/verify.mjs`
74
+ treated any registered command it couldn't resolve to an installed file as
75
+ stray or dead — including a plugin's own `${CLAUDE_PLUGIN_ROOT}`-based
76
+ registration, which it was never meant to check. Now reported as its own
77
+ always-ok informational line instead. (EPIC-008 / TASK-072)
78
+ - **Adoption report no longer prints a false churn verdict over doubled
79
+ events.** Running the plugin and `tune-context init` together on one repo
80
+ registers each hook twice, so every event logs twice — this previously
81
+ surfaced as spurious "elevated re-read churn." The report now detects a
82
+ near-duplicate signature (records identical on session/tool/lever/detail
83
+ within a measured 100ms window, at ≥10% of assessable records) and withholds
84
+ the re-read-churn verdict instead of printing a number the doubling would
85
+ have faked. Thresholds are frozen from two independent real logs, not
86
+ guessed — a naively "safe" 1000ms window was shown to swallow real churn
87
+ instead. (EPIC-008 / TASK-072)
88
+ - **Generated commands now match the detected package manager exactly.**
89
+ `CLAUDE.md` and `permissions.allow` used to assume npm-style `run` commands
90
+ regardless of lockfile. Now derived from one `runCmd(packageManager, script)`
91
+ table: npm/pnpm use `npm run build` / `pnpm run build` (`test` keeps its
92
+ shorthand), yarn uses the bare short form for all three — `yarn build`,
93
+ `yarn test`, `yarn lint` (PO decision). (EPIC-008 / TASK-073)
94
+ - **`disabledMcpjsonServers` is now honoured.** A project `.mcp.json` server a
95
+ user rejected via `disabledMcpjsonServers` (in any settings file) no longer
96
+ gets a `permissions.allow` rule or a `CLAUDE.md` mention — previously
97
+ `tune-context init` wired it in regardless. Three neighbouring keys
98
+ (`enabledMcpjsonServers`, `allowedMcpServers`, `deniedMcpServers`) are
99
+ deliberately not treated as equivalent, verified against docs rather than
100
+ guessed — each would need introspection this CLI doesn't have.
101
+ (EPIC-008 / TASK-075)
102
+ - **PreCompact snapshot files could collide across projects.** `state/` keyed
103
+ snapshots by a human-readable slug of `cwd` alone, which is not injective —
104
+ `/home/a/b` and `/home/a-b` (also `.`/`_` variants) slugged to the same
105
+ filename, so `SessionStart(compact)` could reinject one project's git state
106
+ into an unrelated session. A short hash of the real `cwd` is now appended to
107
+ the filename. **Breaking for `state/` only**: snapshots written under the old
108
+ filename shape are orphaned (harmless — a snapshot is only ever valid for the
109
+ one compaction right after it) rather than reused; the new retention policy
110
+ below cleans them up over time. (EPIC-008 / TASK-076)
111
+ - **`pre-compact-snapshot.mjs` could exit non-zero.** Brought to parity with
112
+ `adoption-log.mjs`: an unwritable state directory (read-only volume,
113
+ misdirected `CLAUDE_CONFIG_DIR`) now fails the snapshot silently instead of
114
+ surfacing a nonzero exit from a hook at the exact moment a session is
115
+ compacting under context pressure. (EPIC-008 / TASK-076)
116
+ - **Manual hook-install instructions now produce a working hook.** README
117
+ pointed at the raw `settings.json.tmpl`, which still has an unsubstituted
118
+ `{{HOOKS_DIR}}` placeholder and no instruction to replace it — followed
119
+ literally, the hook command was `node "{{HOOKS_DIR}}/adoption-log.mjs"` and
120
+ silently never ran. Replaced with a ready-to-paste `settings.json` snippet.
121
+ (EPIC-008 / TASK-078)
122
+ - **Plugin-only installs now get a working `tune-context` command.** Claude
123
+ Code exposes `bin/` files under their literal filename — confirmed against
124
+ current docs, no extension-stripping — so `bin/tune-context.mjs` only ever
125
+ produced a bare `tune-context.mjs`. A plugin-only install (no
126
+ `npm install -g`) got "command not found" for the command README documents.
127
+ Added an extensionless `bin/tune-context` alias so both channels give the
128
+ identical command. (EPIC-008 / TASK-078)
129
+ - **`tune-context --cwd` (no value) no longer crashes with a Node stacktrace.**
130
+ `resolve(undefined)` used to throw `ERR_INVALID_ARG_TYPE` straight to the
131
+ terminal; now reports "Missing value for `--cwd`" plus usage, on stderr,
132
+ exit 1. **Breaking:** an unrecognized *or missing* subcommand is now also
133
+ rejected the same way instead of silently running `init` — `init` is the
134
+ only accepted subcommand. README never documented calling this tool with no
135
+ arguments; every example already used explicit `init`. (EPIC-008 / TASK-078)
136
+
137
+ ### Changed
138
+
139
+ - **One source of truth for `CLAUDE_CONFIG_DIR` resolution.** `cli/detect.mjs`'s
140
+ `claudeDirs()` now delegates the `CLAUDE_CONFIG_DIR || ~/.claude` rule to
141
+ `hooks/config-dir.mjs`'s `claudeBaseDir()` instead of re-implementing it —
142
+ the two could previously drift apart. No behavior change.
143
+ (EPIC-008 / TASK-074)
144
+ - **Removed a dead `permissions` block from the settings template.**
145
+ `settings.json.tmpl` carried a `permissions.allow` block nothing ever read —
146
+ the CLI merges its own, computed from the detected stack, straight into the
147
+ *project's* settings instead. PO-approved removal. (EPIC-008 / TASK-074)
148
+ - **`bin/tune-context.mjs`'s header states its real scope.** No longer reads
149
+ as a full twin of the `/tune-context` skill — names the two steps it
150
+ deliberately can't do (merging in a semantic-search MCP server, wiki-lever
151
+ wiring) instead of leaving the gap unstated. (EPIC-008 / TASK-075)
152
+
153
+ ### Added
154
+
155
+ - **`state/` retention.** Snapshots untouched for 30 days are pruned on the
156
+ next PreCompact write anywhere on the machine — `state/` is one shared
157
+ directory across all projects, so any project's compaction sweeps it, not
158
+ only the project being written; the snapshot just written is never a prune
159
+ candidate. Previously `state/` was the only tune-context artifact with no
160
+ cleanup policy. (EPIC-008 / TASK-076)
161
+ - **`tune-context --version`** (also `-v`), printing the bare number so
162
+ `$(tune-context --version)` is directly comparable. Read from the shipped
163
+ `package.json` — the same file `verify:plugin` cross-checks against
164
+ `plugin.json` and `marketplace.json`, so the CLI can't report a version the
165
+ manifests disagree with.
166
+ - **README: "Keeping context lean while you work".** The tool configures a
167
+ lean setup, but shedding accumulated context stays a deliberate, recurring
168
+ act — `/compact` when a thread closes, `/clear` when switching tasks — and
169
+ the README never said so. Includes what the PreCompact/SessionStart pair
170
+ does and does not preserve across a compaction.
171
+ - **`token-efficiency` §6 recommends `eval/adoption-report.mjs`.** Previously
172
+ the section on measuring whether doctrine is actually used pointed only at
173
+ `rag-usage`, a different package's tool — silent about the one instrument
174
+ this repo ships for the same purpose. (EPIC-008 / TASK-078)
175
+
176
+ ### Testing / packaging
177
+
178
+ - **Unit test harness for the CLI.** `npm test` now also exercises
179
+ `cli/managed-block.mjs`, `cli/generate.mjs`, `cli/verify.mjs` and
180
+ `cli/detect.mjs` directly — previously only reachable end-to-end through
181
+ `smoke:init` — plus a structural guard that fails when a `cli/` export has
182
+ nothing importing it. (EPIC-008 / TASK-077)
183
+ - **`verify:plugin` and `verify:pack` now guard `bin/` and
184
+ `eval/adoption-report.mjs`.** Both were tracked by git accidentally, not by
185
+ contract, despite README telling a plugin-only or npm install to run them —
186
+ a clone or tarball missing either would previously pass both checks anyway.
187
+ (EPIC-008 / TASK-078)
188
+
189
+ ### Upgrading from 1.1.0
190
+
191
+ Re-running `tune-context init` over a 1.1.0 install is safe and was verified
192
+ end-to-end: hook entries are not duplicated, unrelated `settings.json` keys and
193
+ hand-written `CLAUDE.md` content are untouched, and your own
194
+ `permissions.allow` rules are kept.
195
+
196
+ Two leftovers are deliberate, because removing them would break "never
197
+ clobber" — tune-context cannot tell its own past output from a rule you added
198
+ on purpose:
199
+
200
+ - **pnpm/yarn projects**: the npm-style rules 1.1.0 wrote (`Bash(npm test:*)`
201
+ and friends) stay in `.claude/settings.json` alongside the correct new ones
202
+ (`Bash(pnpm test:*)`). Harmless — an unused allow rule grants nothing extra
203
+ for a command you never run — but safe to delete by hand.
204
+ - **PreCompact snapshots** under `<config dir>/tune-context/state/` written
205
+ before this release keep their old filenames and are ignored. The new
206
+ 30-day retention deletes them on its own; no action needed.
207
+
8
208
  ## [1.1.0] — 2026-07-24
9
209
 
10
210
  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, PostToolUse adoption log + hooks.json
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, no-model twin of the skill — same
74
- managed-block rules, same never-clobber guarantee. Good for CI or scripted setup:
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
- - add the `PostToolUse` **and** `SubagentStart` blocks from
153
- [`skills/tune-context/templates/settings.json.tmpl`](skills/tune-context/templates/settings.json.tmpl)
154
- to your `settings.json` `~/.claude/settings.json` for every project, or a
155
- repo's `.claude/settings.json` for just that one.
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,
@@ -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();
@@ -1,10 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
  // tune-context init — deterministic detect -> generate -> verify.
3
- // This is the CLI/plugin-bin twin of skills/tune-context/SKILL.md: same
4
- // managed-block rules, same "never clobber" guarantee, no model in the loop.
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, realpathSync } 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
- console.log(`tune-context init — configure this repo for token-efficient Claude Code use.
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
- const mcpServers = detectMcpServers(cwd);
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,55 @@ function runInit(cwd) {
79
155
  if (!result.ok) process.exitCode = 1;
80
156
  }
81
157
 
82
- const args = process.argv.slice(2);
83
- if (args.includes('--help') || args.includes('-h')) {
84
- usage();
85
- } else {
86
- const cwdFlagIdx = args.indexOf('--cwd');
87
- const cwd = cwdFlagIdx !== -1 ? resolve(args[cwdFlagIdx + 1]) : process.cwd();
88
- runInit(cwd);
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
+ /**
177
+ * True only when THIS file is the process entry point, so importing it for
178
+ * parseArgs/main (tests, or the extensionless bin/tune-context wrapper) never
179
+ * runs the CLI as a side effect of the import.
180
+ *
181
+ * Deliberately NOT a filename-suffix test on argv[1]. npm installs the
182
+ * package.json `bin` entry as a symlink named `tune-context` — no extension —
183
+ * so `argv[1].endsWith('tune-context.mjs')` was false for every real npm-channel
184
+ * invocation: main() never ran and the CLI exited 0 in total silence. That
185
+ * shipped in 2.0.0 (see the symlink test in test/bin-tune-context.test.mjs);
186
+ * the whole suite missed it because tests and smoke:init both spawn the full
187
+ * `.../bin/tune-context.mjs` path, which is the one shape a user never uses.
188
+ *
189
+ * Comparing resolved real paths holds for all four shapes: direct `.mjs`
190
+ * invocation, the npm symlink (resolves THROUGH the link to this file), the
191
+ * extensionless wrapper (a different real path, so this stays false and the
192
+ * wrapper's own main() call is the single entry — no double run), and `import`
193
+ * from a test (argv[1] is the test runner). realpathSync on both sides also
194
+ * survives a symlinked package dir, as `npm link` and nvm both produce.
195
+ */
196
+ function isEntryPoint() {
197
+ if (!process.argv[1]) return false;
198
+ try {
199
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
200
+ } catch {
201
+ // argv[1] can name something unresolvable (deleted, or a bare `-`); a CLI
202
+ // that cannot identify its own entry point must stay silent, not crash.
203
+ return false;
204
+ }
205
+ }
206
+
207
+ if (isEntryPoint()) {
208
+ main();
89
209
  }