@bglocation/tune-context 1.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.
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "bglocation",
3
+ "owner": {
4
+ "name": "Szymon Walczak",
5
+ "url": "https://bglocation.dev"
6
+ },
7
+ "plugins": [
8
+ {
9
+ "name": "tune-context",
10
+ "source": "./",
11
+ "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.0",
13
+ "author": {
14
+ "name": "Szymon Walczak",
15
+ "url": "https://bglocation.dev"
16
+ }
17
+ }
18
+ ]
19
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "tune-context",
3
+ "displayName": "tune-context",
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.0",
6
+ "author": {
7
+ "name": "Szymon Walczak",
8
+ "url": "https://bglocation.dev"
9
+ },
10
+ "homepage": "https://bglocation.dev",
11
+ "repository": "https://gitlab.com/bglocation/tune-context",
12
+ "license": "MIT",
13
+ "keywords": [
14
+ "claude-code",
15
+ "context-engineering",
16
+ "token-efficiency",
17
+ "configurator",
18
+ "skills"
19
+ ]
20
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Szymon Walczak
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,199 @@
1
+ # tune-context
2
+
3
+ Context-efficiency configurator for Claude Code — the doctrine (token-saving
4
+ working methods) packaged as **skills**, plus a `tune-context` configurator that
5
+ scaffolds a cost-optimal, high-quality setup (lean CLAUDE.md + skills + subagents
6
+ + settings + hooks + MCP).
7
+
8
+ **Self-contained** — zero dependency on any host repo. Ships two ways: a Claude
9
+ Code **plugin** and an **npm CLI** (`tune-context init`), both under
10
+ `@bglocation`. Incubated inside the [`code-search-mcp`](https://gitlab.com/bglocation/code-search-mcp)
11
+ project, now extracted to its own repository.
12
+
13
+ ```
14
+ tune-context/
15
+ ├── .claude-plugin/plugin.json # Claude Code plugin manifest
16
+ ├── skills/ # token-efficiency, caveman, cdd, phase-workflow, tune-context (+ templates/)
17
+ ├── agents/ # explore-cheap, reviewer (model right-sizing)
18
+ ├── hooks/ # PreCompact state-doc, SessionStart re-inject, PostToolUse adoption log + hooks.json
19
+ ├── bin/ + cli/ # `tune-context init` — deterministic detect → generate → verify
20
+ ├── eval/ # adoption-report.mjs (the rag-usage analog)
21
+ └── scripts/ # verify-pack, smoke-init, sync-tune-context (dev/maintenance)
22
+ ```
23
+
24
+ ## Quick start
25
+
26
+ Node ≥ 18, no build step. From the repo you want to configure:
27
+
28
+ ```bash
29
+ # Configure this repo — deterministic, no Claude session needed
30
+ npx -p @bglocation/tune-context tune-context init
31
+ ```
32
+
33
+ > The `-p <pkg> tune-context` form is deliberate: the package is **scoped**
34
+ > (`@bglocation/…`) and its bin (`tune-context`) doesn't match the scope, so a
35
+ > bare `npx @bglocation/tune-context` can fail to resolve the command. `-p`
36
+ > installs the package and names the bin explicitly.
37
+
38
+ That single command detects the stack + MCP servers, writes a lean `CLAUDE.md`,
39
+ syncs the doctrine skills/agents/hooks into `~/.claude/`, merges hooks and
40
+ permissions, and prints a verification report. It's idempotent — safe to re-run.
41
+
42
+ Install it globally if you'll use it across repos:
43
+
44
+ ```bash
45
+ npm install -g @bglocation/tune-context
46
+ tune-context init # in any repo
47
+ ```
48
+
49
+ Prefer the model-driven path? Install the plugin and use the skill — it does the
50
+ same thing plus wiki wiring and richer MCP detection:
51
+
52
+ ```bash
53
+ claude --plugin-dir "$(npm root -g)/@bglocation/tune-context" # global install
54
+ # then, inside a session in the repo you want to configure:
55
+ /tune-context
56
+ ```
57
+
58
+ The section below breaks down all three entry points and when to use each.
59
+
60
+ ## Install & run
61
+
62
+ Three entry points, same doctrine. Pick whichever fits how you work:
63
+
64
+ **1. `/tune-context` skill (recommended).** With the plugin installed (below), run
65
+ `/tune-context` in any repo. It detects the stack, MCP servers and existing config,
66
+ then generates a lean project `CLAUDE.md`, wires the doctrine skills, and merges
67
+ hooks + permissions — idempotently, never clobbering hand-written content. This is
68
+ the full-fidelity path (it also handles wiki wiring and semantic-search detection).
69
+
70
+ **2. CLI `tune-context init`.** The deterministic, no-model twin of the skill — same
71
+ managed-block rules, same never-clobber guarantee. Good for CI or scripted setup:
72
+
73
+ ```bash
74
+ npx -p @bglocation/tune-context tune-context init # runs in the current repo
75
+ npx -p @bglocation/tune-context tune-context init --cwd . # explicit target dir
76
+ # from a checkout (dev):
77
+ node bin/tune-context.mjs init --cwd /path/to/your/repo
78
+ ```
79
+
80
+ It prints what it detected, what it synced/merged, and a verification report; a
81
+ second run is a no-op.
82
+
83
+ **3. Plugin install.** Load the plugin so the skills, agents and hooks are available
84
+ in every session. The best UX is the **marketplace** — install once, get updates
85
+ with `/plugin marketplace update`:
86
+
87
+ ```text
88
+ /plugin marketplace add https://gitlab.com/bglocation/tune-context
89
+ /plugin install tune-context@bglocation
90
+ ```
91
+
92
+ Or point Claude at a directory directly (no marketplace):
93
+
94
+ ```bash
95
+ claude --plugin-dir "$(npm root -g)/@bglocation/tune-context" # global npm install
96
+ claude --plugin-dir /path/to/tune-context # or a local checkout
97
+ ```
98
+
99
+ The plugin manifest (`.claude-plugin/plugin.json`) and hook wiring
100
+ (`hooks/hooks.json`, via `${CLAUDE_PLUGIN_ROOT}`) register the four hook events
101
+ automatically; `bin/tune-context` is added to `PATH` while the plugin is enabled.
102
+ The marketplace catalog is [`.claude-plugin/marketplace.json`](.claude-plugin/marketplace.json).
103
+
104
+ ## Measuring adoption (does the doctrine actually get used?)
105
+ `hooks/adoption-log.mjs` logs one JSONL line per event to
106
+ `~/.claude/tune-context/adoption.jsonl`, and `eval/adoption-report.mjs` turns that
107
+ into a report: RAG vs grep, doctrine skills, subagents, re-read churn — with a
108
+ verdict per lever. It's registered under two Claude Code hook events —
109
+ `PostToolUse` (every tool call) and `SubagentStart` (subagent spawns, which aren't
110
+ regular tools).
111
+
112
+ ### Turn it on
113
+
114
+ 1. **Install the hook.** Either run **`/tune-context`** in your repo (it copies the
115
+ script to `~/.claude/hooks/` and merges the hook entries into `settings.json`
116
+ for you), or do it by hand:
117
+ - copy `hooks/adoption-log.mjs` → `~/.claude/hooks/adoption-log.mjs` (needs
118
+ Node ≥ 18 on `PATH`);
119
+ - add the `PostToolUse` **and** `SubagentStart` blocks from
120
+ [`skills/tune-context/templates/settings.json.tmpl`](skills/tune-context/templates/settings.json.tmpl)
121
+ to your `settings.json` — `~/.claude/settings.json` for every project, or a
122
+ repo's `.claude/settings.json` for just that one.
123
+
124
+ 2. **No reload needed.** Claude Code watches the settings files, so the new hooks
125
+ go live within a few seconds of saving — **you do not need to reload VSCode,
126
+ restart the extension, or start a new session.** Only if nothing shows up after
127
+ a few seconds, restart the session to force a reload. Check what's active with
128
+ the `/hooks` command.
129
+
130
+ 3. **Just work.** Every tool call and subagent run now appends a line to
131
+ `~/.claude/tune-context/adoption.jsonl` (created on first write).
132
+
133
+ 4. **Read the report** any time, from this checkout (or the installed package):
134
+ ```bash
135
+ node eval/adoption-report.mjs # default log location
136
+ node eval/adoption-report.mjs --log <path>
137
+ ```
138
+
139
+ **Stop / opt out:** delete the `PostToolUse` + `SubagentStart` entries from
140
+ `settings.json` (watcher picks that up too). The log stays local under
141
+ `~/.claude/tune-context/` and records only tool names, file paths, and
142
+ skill/agent names — never full Bash command strings.
143
+
144
+ **Honest scope:** this is a *behavioral proxy*, not a bill. Token cost lands only
145
+ in OTEL (`CLAUDE_CODE_ENABLE_TELEMETRY=1` → `claude_code.cost.usage`, needs a
146
+ collector), never a hook; and caveman is an output style with no tool call, so it
147
+ can't be counted this way.
148
+
149
+ ### Getting real cost (OTEL) — outside tune-context
150
+
151
+ tune-context does not set this up for you (deliberate — see TASK-062: it needs
152
+ infra this tool stays zero-infra without). If you want actual dollars instead of
153
+ the behavioral proxy above:
154
+
155
+ ```bash
156
+ export CLAUDE_CODE_ENABLE_TELEMETRY=1 # off by default
157
+ export OTEL_METRICS_EXPORTER=prometheus # NOT console — console is CLI-only and
158
+ # conflicts with the SDK/message stream
159
+ # a VSCode-extension session may use
160
+ export OTEL_METRIC_EXPORT_INTERVAL=1000 # default 60000ms; short sessions can
161
+ # exit before ever exporting
162
+ ```
163
+
164
+ Start a new Claude Code session with those set (must be inherited at launch —
165
+ e.g. exported in the shell profile before opening VSCode, not just typed into an
166
+ in-editor terminal), work for a bit, then:
167
+
168
+ ```bash
169
+ curl -s http://localhost:9464/metrics | grep claude_code
170
+ ```
171
+
172
+ Look for **`claude_code.cost.usage`** (USD, carries `session.id`) and
173
+ **`claude_code.token.usage`** (token counts). No collector needed just to see the
174
+ numbers — `curl` against the local Prometheus endpoint is enough; wire a real
175
+ Prometheus/Grafana at that endpoint only if you want history instead of a
176
+ point-in-time read. Traces (`OTEL_TRACES_EXPORTER=otlp`) need an extra
177
+ `CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1` flag and aren't needed just for cost.
178
+
179
+ ## Design principles
180
+ - **Minimal always-on**, depth on-demand: thin CLAUDE.md points at skills; skill
181
+ *bodies* load only when invoked (progressive disclosure).
182
+ - **`@import` is not a cost lever** — it is inlined in full. Reduce via skills.
183
+ - **Right-size the model per role** (subagent `model:`).
184
+ - **Caveman targets output** (uncached, ~4–5× input); skills/subagents target the
185
+ window.
186
+
187
+ ## Local dogfooding
188
+ `node scripts/sync-tune-context.mjs` mirrors `skills/`, `agents/` and `hooks/`
189
+ into `~/.claude/` (hooks get `chmod 755`). Source of truth stays here. Skills take
190
+ effect on the next Claude Code session; hooks are hot-reloaded by the settings
191
+ file watcher (once their `settings.json` entries exist).
192
+
193
+ ## Related — the `@bglocation` toolbelt
194
+ - **[`@bglocation/code-search-mcp`](https://gitlab.com/bglocation/code-search-mcp)**
195
+ — semantic code index (RAG) + MCP server. The strongest lever tune-context
196
+ detects and wires: search by meaning instead of `grep`, read only the returned
197
+ spans. tune-context runs fine without it, but recommends it when no
198
+ semantic-search MCP is present.
199
+ - **[bglocation.dev](https://bglocation.dev)** — the rest of the toolbelt.
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: explore-cheap
3
+ description: Cheap read-only fan-out for broad code/file searches — "where is everything about X", naming-convention hunts, locating code across many files. Returns a tight conclusion, not raw dumps. Use instead of grepping many files in the main session.
4
+ tools: Read, Grep, Glob
5
+ model: haiku
6
+ ---
7
+
8
+ # explore-cheap
9
+
10
+ Read-only search fan-out on a cheap model. You sweep many files and return a
11
+ tight conclusion — the main session pays for the answer, not the search.
12
+
13
+ - Locate code/config across the repo; map naming conventions; find all call sites.
14
+ - Read excerpts, not whole files. Report `path:line` references plus a short synthesis.
15
+ - **Read-only:** never edit, run builds, or change anything.
16
+ - You do file-level search (grep/glob). Semantic "where/why is X" search is the
17
+ main session's job (`search_codebase`); you handle the exhaustive sweeps it
18
+ would rather not pull into its context.
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: reviewer
3
+ description: Reviews a diff or a slice of code for correctness bugs and clear simplifications — judgment work, not mechanical search. Returns findings ranked by severity with rationale. Use for a second-pass review before committing non-trivial changes.
4
+ tools: Read, Grep, Glob, Bash
5
+ model: fable
6
+ ---
7
+
8
+ # reviewer
9
+
10
+ Second-pass review on a strong model — this is judgment, so it is worth the
11
+ better model (right-sizing: cheap for search, strong for review). For the hardest
12
+ reviews, override to `opus` per invocation.
13
+
14
+ - Correctness bugs first, then clear simplifications / reuse.
15
+ - Read the diff and enough surrounding code to judge — don't rewrite, report.
16
+ - Return findings ranked by severity, each with a one-line rationale and a
17
+ `path:line` anchor.
18
+ - Prefer `git diff` over re-reading whole files.
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
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.
5
+ import { readFileSync } from 'node:fs';
6
+ import { dirname, join, resolve } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { claudeDirs, detectExistingConfig, detectMcpServers, detectStack } from '../cli/detect.mjs';
9
+ import {
10
+ buildPermissionsAllow,
11
+ generateProjectClaudeMd,
12
+ generateUserClaudeMd,
13
+ installDoctrine,
14
+ mergeHooks,
15
+ mergePermissions,
16
+ } from '../cli/generate.mjs';
17
+ import { verify } from '../cli/verify.mjs';
18
+
19
+ const HOOK_EVENTS = [
20
+ { event: 'PreCompact', scriptName: 'pre-compact-snapshot.mjs' },
21
+ { event: 'SessionStart', scriptName: 'session-start-reinject.mjs' },
22
+ { event: 'PostToolUse', scriptName: 'adoption-log.mjs' },
23
+ { event: 'SubagentStart', scriptName: 'adoption-log.mjs' },
24
+ ];
25
+
26
+ function usage() {
27
+ console.log(`tune-context init — configure this repo for token-efficient Claude Code use.
28
+
29
+ Usage:
30
+ tune-context init [--cwd <dir>]
31
+ tune-context --help
32
+ `);
33
+ }
34
+
35
+ function runInit(cwd) {
36
+ const here = dirname(fileURLToPath(import.meta.url));
37
+ const pkgRoot = resolve(here, '..');
38
+ const templatesDir = join(pkgRoot, 'skills', 'tune-context', 'templates');
39
+ const { claudeDir } = claudeDirs();
40
+
41
+ const stack = detectStack(cwd);
42
+ const existing = detectExistingConfig(cwd);
43
+ const mcpServers = detectMcpServers(cwd);
44
+ const detection = { stack, mcpServers };
45
+
46
+ const synced = installDoctrine(pkgRoot, claudeDir);
47
+ const userClaudeMd = generateUserClaudeMd(claudeDir, templatesDir);
48
+ const projectClaudeMd = generateProjectClaudeMd(cwd, detection, templatesDir);
49
+
50
+ const settingsTemplate = JSON.parse(readFileSync(join(templatesDir, 'settings.json.tmpl'), 'utf8'));
51
+ const hooksAdded = mergeHooks(existing.userSettingsJson, { hooks: settingsTemplate.hooks });
52
+ const permissionsAllow = buildPermissionsAllow(detection);
53
+ const permissionsAdded = mergePermissions(existing.projectSettingsJson, permissionsAllow);
54
+
55
+ const result = verify({
56
+ claudeDir,
57
+ userSettingsPath: existing.userSettingsJson,
58
+ projectSettingsPath: existing.projectSettingsJson,
59
+ expectedHookCommands: HOOK_EVENTS,
60
+ expectedPermissions: permissionsAllow,
61
+ });
62
+
63
+ console.log(`tune-context init — ${cwd}\n`);
64
+ console.log(`Stack: ${stack.ecosystem}${stack.name ? ` (${stack.name})` : ''}${stack.monorepo ? ', monorepo' : ''}`);
65
+ if (stack.undetectedCommands) {
66
+ console.log(' build/test/lint commands not detected for this ecosystem — filled with TODO placeholders in CLAUDE.md.');
67
+ }
68
+ console.log(`\nDoctrine synced to ${claudeDir}: ${synced.length} item(s)`);
69
+ console.log(`User CLAUDE.md: ${userClaudeMd.changed ? 'written' : `skipped (${userClaudeMd.skipped})`}`);
70
+ console.log(`Project CLAUDE.md: ${projectClaudeMd.changed ? `updated ${projectClaudeMd.path}` : 'already up to date'}`);
71
+ console.log(`Hooks merged into ${existing.userSettingsJson}: ${hooksAdded.length ? hooksAdded.join(', ') : 'already present'}`);
72
+ console.log(`Permissions merged into ${existing.projectSettingsJson}: ${permissionsAdded.length ? permissionsAdded.join(', ') : 'already present'}`);
73
+ console.log(`MCP servers detected: ${mcpServers.length ? mcpServers.map((s) => `${s.name} (${s.scope}, ${s.classification})`).join(', ') : 'none'}`);
74
+
75
+ console.log(`\nVerify: ${result.ok ? 'OK' : 'FAILED'}`);
76
+ for (const check of result.checks) {
77
+ console.log(` ${check.ok ? '✓' : '✗'} ${check.name}${check.detail ? ` — ${check.detail}` : ''}`);
78
+ }
79
+ if (!result.ok) process.exitCode = 1;
80
+ }
81
+
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);
89
+ }
package/cli/detect.mjs ADDED
@@ -0,0 +1,114 @@
1
+ // Read-only detection: stack, existing config, MCP servers across every
2
+ // scope. Facts over guesses — every value here is read from a real file, none
3
+ // invented. Mirrors skills/tune-context/SKILL.md §1 "Detect".
4
+ import { existsSync, readFileSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+
8
+ function readJson(path) {
9
+ try {
10
+ return JSON.parse(readFileSync(path, 'utf8'));
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
15
+
16
+ /** Resolve the base Claude config directory, honoring CLAUDE_CONFIG_DIR. */
17
+ export function claudeDirs() {
18
+ const override = process.env.CLAUDE_CONFIG_DIR;
19
+ return {
20
+ claudeDir: override || join(homedir(), '.claude'),
21
+ claudeJsonPath: override ? join(override, '.claude.json') : join(homedir(), '.claude.json'),
22
+ };
23
+ }
24
+
25
+ /** Package manager / language / verified scripts / monorepo shape. Facts only — no guessed commands. */
26
+ export function detectStack(cwd) {
27
+ const pkgPath = join(cwd, 'package.json');
28
+ const pkg = readJson(pkgPath);
29
+ if (pkg) {
30
+ const scripts = pkg.scripts || {};
31
+ const monorepo = Boolean(
32
+ pkg.workspaces || existsSync(join(cwd, 'pnpm-workspace.yaml')) || existsSync(join(cwd, 'turbo.json')) || existsSync(join(cwd, 'nx.json')),
33
+ );
34
+ return {
35
+ ecosystem: 'node',
36
+ name: pkg.name || null,
37
+ packageManager: existsSync(join(cwd, 'pnpm-lock.yaml'))
38
+ ? 'pnpm'
39
+ : existsSync(join(cwd, 'yarn.lock'))
40
+ ? 'yarn'
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,
45
+ monorepo,
46
+ };
47
+ }
48
+ for (const [file, ecosystem] of [
49
+ ['pyproject.toml', 'python'],
50
+ ['go.mod', 'go'],
51
+ ['Cargo.toml', 'rust'],
52
+ ]) {
53
+ if (existsSync(join(cwd, file))) {
54
+ return { ecosystem, name: null, packageManager: null, buildCmd: null, testCmd: null, lintCmd: null, monorepo: false, undetectedCommands: true };
55
+ }
56
+ }
57
+ return { ecosystem: 'unknown', name: null, packageManager: null, buildCmd: null, testCmd: null, lintCmd: null, monorepo: false, undetectedCommands: true };
58
+ }
59
+
60
+ /** Existing config files this run must merge into, never clobber. */
61
+ export function detectExistingConfig(cwd) {
62
+ const { claudeDir } = claudeDirs();
63
+ return {
64
+ projectClaudeMd: existsSync(join(cwd, 'CLAUDE.md')) ? join(cwd, 'CLAUDE.md') : null,
65
+ userClaudeMd: existsSync(join(claudeDir, 'CLAUDE.md')) ? join(claudeDir, 'CLAUDE.md') : null,
66
+ projectMcpJson: existsSync(join(cwd, '.mcp.json')) ? join(cwd, '.mcp.json') : null,
67
+ projectSettingsJson: join(cwd, '.claude', 'settings.json'),
68
+ userSettingsJson: join(claudeDir, 'settings.json'),
69
+ };
70
+ }
71
+
72
+ /** Heuristic only — classifies by server name/command, does not introspect tools/list. */
73
+ function classifyMcpServer(name, config) {
74
+ const haystack = [name, config?.command, ...(config?.args || [])].filter(Boolean).join(' ').toLowerCase();
75
+ if (/search|rag|embed|semantic|vector/.test(haystack)) return 'semantic code search';
76
+ if (/wiki|confluence|notion|docs?\b/.test(haystack)) return 'wiki / docs';
77
+ if (/postgres|mysql|sqlite|mongo|\bsql\b|\bdb\b/.test(haystack)) return 'database';
78
+ if (/git(hub|lab)?|issue|jira/.test(haystack)) return 'VCS / issues';
79
+ return 'unknown';
80
+ }
81
+
82
+ /**
83
+ * Every MCP server visible to this project, across all three scopes, each
84
+ * tagged with scope + a heuristic classification (name/command keywords —
85
+ * NOT a tools/list introspection; treat as a starting point, not ground truth).
86
+ */
87
+ export function detectMcpServers(cwd) {
88
+ const { claudeJsonPath } = claudeDirs();
89
+ const servers = [];
90
+
91
+ const projectMcp = readJson(join(cwd, '.mcp.json'));
92
+ for (const [name, config] of Object.entries(projectMcp?.mcpServers || {})) {
93
+ servers.push({ scope: 'project', name, classification: classifyMcpServer(name, config) });
94
+ }
95
+
96
+ const claudeJson = readJson(claudeJsonPath);
97
+ for (const [name, config] of Object.entries(claudeJson?.mcpServers || {})) {
98
+ servers.push({ scope: 'user', name, classification: classifyMcpServer(name, config) });
99
+ }
100
+ const projectEntry = claudeJson?.[cwd];
101
+ for (const [name, config] of Object.entries(projectEntry?.mcpServers || {})) {
102
+ servers.push({ scope: 'local', name, classification: classifyMcpServer(name, config) });
103
+ }
104
+
105
+ return servers;
106
+ }
107
+
108
+ /** MCP gating keys from a settings.json — narrows which detected servers are actually reachable. */
109
+ export function detectMcpGating(settingsJsonPath) {
110
+ const settings = readJson(settingsJsonPath);
111
+ if (!settings) return null;
112
+ const { enabledMcpjsonServers, disabledMcpjsonServers, allowedMcpServers, deniedMcpServers } = settings;
113
+ return { enabledMcpjsonServers, disabledMcpjsonServers, allowedMcpServers, deniedMcpServers };
114
+ }
@@ -0,0 +1,123 @@
1
+ // Idempotent generation: project CLAUDE.md (managed block), user CLAUDE.md
2
+ // (only if missing), doctrine skills/agents/hooks, and settings.json merges
3
+ // (hooks as a set keyed by command, permissions.allow as a union). Never
4
+ // clobbers hand-written content. Mirrors SKILL.md §2 "Generate".
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { dirname, join } from 'node:path';
7
+ import { syncDoctrine } from './sync-doctrine.mjs';
8
+ import { upsertManagedBlock } from './managed-block.mjs';
9
+
10
+ function readJson(path, fallback) {
11
+ if (!existsSync(path)) return fallback;
12
+ try {
13
+ return JSON.parse(readFileSync(path, 'utf8'));
14
+ } catch {
15
+ return fallback;
16
+ }
17
+ }
18
+
19
+ function writeJson(path, obj) {
20
+ mkdirSync(dirname(path), { recursive: true });
21
+ writeFileSync(path, `${JSON.stringify(obj, null, 2)}\n`);
22
+ }
23
+
24
+ function fillTemplate(template, slots) {
25
+ let out = template;
26
+ for (const [key, value] of Object.entries(slots)) {
27
+ out = out.replaceAll(`{{${key}}}`, value);
28
+ }
29
+ return out;
30
+ }
31
+
32
+ /** Extract the managed-block inner content from a project CLAUDE.md template file. */
33
+ function templateInnerBlock(templatePath) {
34
+ const raw = readFileSync(templatePath, 'utf8');
35
+ const start = raw.indexOf('<!-- >>> tune-context (managed) >>>');
36
+ const end = raw.indexOf('<!-- <<< tune-context (managed) <<< -->');
37
+ const startLineEnd = raw.indexOf('\n', start) + 1;
38
+ return raw.slice(startLineEnd, end).trimEnd();
39
+ }
40
+
41
+ export function generateProjectClaudeMd(cwd, detection, templatesDir) {
42
+ const templatePath = join(templatesDir, 'CLAUDE.md.project.tmpl');
43
+ const inner = templateInnerBlock(templatePath);
44
+
45
+ const mcpRules = detection.mcpServers.length
46
+ ? detection.mcpServers.map((s) => `- \`${s.name}\` (${s.scope}, ${s.classification}) — see token-efficiency skill for the matching rule.`).join('\n')
47
+ : '<!-- fill in: no MCP server detected — none, or add one -->';
48
+
49
+ const filled = fillTemplate(inner, {
50
+ PROJECT_NAME: detection.stack.name || '<!-- fill in: project name -->',
51
+ ONE_LINE_WHAT_THIS_IS: '<!-- fill in: one sentence, what this project is -->',
52
+ BUILD_CMD: detection.stack.buildCmd || '# no build script detected — fill in or delete this line',
53
+ TEST_CMD: detection.stack.testCmd || '# no test script detected — fill in or delete this line',
54
+ LINT_CMD: detection.stack.lintCmd || '# no lint script detected — fill in or delete this line',
55
+ RUN_NOTES: detection.stack.monorepo ? '<!-- monorepo detected — note which package/workspace to run commands from -->' : '',
56
+ MCP_RULES: mcpRules,
57
+ ARCHITECTURE_BULLETS: '<!-- fill in: 3-6 lines, module -> responsibility -->',
58
+ CONVENTIONS: '<!-- fill in: only rules that bite -->',
59
+ });
60
+
61
+ const path = join(cwd, 'CLAUDE.md');
62
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : null;
63
+ const { content, changed } = upsertManagedBlock(existing, filled);
64
+ if (changed) writeFileSync(path, content);
65
+ return { path, changed };
66
+ }
67
+
68
+ export function generateUserClaudeMd(claudeDir, templatesDir) {
69
+ const path = join(claudeDir, 'CLAUDE.md');
70
+ if (existsSync(path)) return { path, changed: false, skipped: 'already exists — never overwrite a user-tuned file' };
71
+ const templatePath = join(templatesDir, 'CLAUDE.md.user.tmpl');
72
+ mkdirSync(claudeDir, { recursive: true });
73
+ writeFileSync(path, readFileSync(templatePath, 'utf8'));
74
+ return { path, changed: true };
75
+ }
76
+
77
+ export function installDoctrine(pkgRoot, claudeDir) {
78
+ return syncDoctrine(pkgRoot, claudeDir);
79
+ }
80
+
81
+ /** Merge the hooks template into a settings.json, keyed by `command` so a second run is a no-op. */
82
+ export function mergeHooks(settingsPath, hooksTemplate) {
83
+ const settings = readJson(settingsPath, {});
84
+ settings.hooks = settings.hooks || {};
85
+ const added = [];
86
+ for (const [event, groups] of Object.entries(hooksTemplate.hooks)) {
87
+ settings.hooks[event] = settings.hooks[event] || [];
88
+ for (const group of groups) {
89
+ for (const hook of group.hooks) {
90
+ const alreadyPresent = settings.hooks[event].some((g) => g.hooks?.some((h) => h.command === hook.command));
91
+ if (!alreadyPresent) {
92
+ settings.hooks[event].push(group);
93
+ added.push(`${event}: ${hook.command}`);
94
+ }
95
+ }
96
+ }
97
+ }
98
+ if (added.length) writeJson(settingsPath, settings);
99
+ return added;
100
+ }
101
+
102
+ /** Union permissions.allow with the detected commands — dedupe, never remove entries. */
103
+ export function mergePermissions(settingsPath, allowEntries) {
104
+ const settings = readJson(settingsPath, {});
105
+ settings.permissions = settings.permissions || {};
106
+ const existing = settings.permissions.allow || [];
107
+ const merged = [...new Set([...existing, ...allowEntries])];
108
+ const added = allowEntries.filter((e) => !existing.includes(e));
109
+ if (added.length) {
110
+ settings.permissions.allow = merged;
111
+ writeJson(settingsPath, settings);
112
+ }
113
+ return added;
114
+ }
115
+
116
+ export function buildPermissionsAllow(detection) {
117
+ const allow = ['Bash(git status:*)', 'Bash(git diff:*)', 'Bash(git log:*)'];
118
+ if (detection.stack.buildCmd) allow.push(`Bash(${detection.stack.buildCmd}:*)`);
119
+ if (detection.stack.testCmd) allow.push(`Bash(${detection.stack.testCmd}:*)`);
120
+ if (detection.stack.lintCmd) allow.push(`Bash(${detection.stack.lintCmd}:*)`);
121
+ for (const server of detection.mcpServers) allow.push(`mcp__${server.name}__*`);
122
+ return allow;
123
+ }
@@ -0,0 +1,26 @@
1
+ // Idempotent managed-block merge for CLAUDE.md — replace only what's between
2
+ // the markers; hand-written content outside them is never touched. If the
3
+ // markers are absent, append a fresh block instead of rewriting the file.
4
+ export const START_MARKER = '<!-- >>> tune-context (managed) >>>';
5
+ export const END_MARKER = '<!-- <<< tune-context (managed) <<< -->';
6
+
7
+ /**
8
+ * @param {string|null} existing - current file content, or null if the file doesn't exist yet
9
+ * @param {string} innerBlock - the freshly generated managed content (without markers)
10
+ * @returns {{content: string, changed: boolean}}
11
+ */
12
+ export function upsertManagedBlock(existing, innerBlock) {
13
+ const block = `${START_MARKER}\n${innerBlock}\n${END_MARKER}`;
14
+ if (existing == null) {
15
+ return { content: `${block}\n`, changed: true };
16
+ }
17
+ const startIdx = existing.indexOf(START_MARKER);
18
+ const endIdx = existing.indexOf(END_MARKER);
19
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
20
+ const next = existing.slice(0, startIdx) + block + existing.slice(endIdx + END_MARKER.length);
21
+ return { content: next, changed: next !== existing };
22
+ }
23
+ const sep = existing.length === 0 || existing.endsWith('\n') ? '' : '\n';
24
+ const next = `${existing}${sep}\n${block}\n`;
25
+ return { content: next, changed: true };
26
+ }