@bglocation/tune-context 2.1.0 → 2.3.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.
@@ -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": "2.1.0",
13
+ "version": "2.3.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": "2.1.0",
5
+ "version": "2.3.0",
6
6
  "author": {
7
7
  "name": "Szymon Walczak",
8
8
  "url": "https://bglocation.dev"
package/CHANGELOG.md CHANGED
@@ -5,6 +5,105 @@ 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.3.0] — 2026-07-28
9
+
10
+ ### Added
11
+
12
+ - **The adoption report can now tell "RAG installed and ignored" from "no RAG
13
+ here"** — the blind spot 2.0.0 accepted on purpose, and the finding the report
14
+ most wants to surface. A new `~ RAG likely available, not reached for` line
15
+ appears when a project's `.mcp.json` declares a semantic-search MCP server, no
16
+ settings file rejects it, and that file is **older than every session logged in
17
+ that project**. It is never a ✗: config lists servers, not their tools, so the
18
+ claim is "probably within reach", not "it existed". Four guards keep it from
19
+ fabricating one — rejected servers (`disabledMcpjsonServers`) don't count, a
20
+ config file touched during or after the sessions drops back to unknown,
21
+ observation always wins (a project that actually called `search_codebase` is
22
+ never judged from config), and the frozen session floor now applies **per
23
+ project** as well as globally, because the claim is per project: a busy log can
24
+ still contain a project with a single session, and absence over one session is
25
+ not absence. User-scope servers stay unknown by design: `~/.claude.json` is
26
+ rewritten by Claude Code continuously, so its timestamp can't date anything
27
+ (TASK-083).
28
+
29
+ ### Fixed
30
+
31
+ - **One RAG-using project no longer counts as coverage for all the others.**
32
+ Search was tallied globally while the verdict spoke about "these sessions", so
33
+ a single project with RAG lifted the counter above zero and every other project
34
+ read as covered. Search is now counted per project as well, which is what
35
+ TASK-070 specified and never landed — visible in the report as an availability
36
+ verdict decided project by project.
37
+
38
+ - **A malformed log line can no longer take the whole report down.** `parseLog`
39
+ validates only `lever`, so `cwd` could be any JSON value — and the new probe
40
+ joins it into a filesystem path. Non-string `cwd`s are now ignored as projects
41
+ while still counting toward the global figures.
42
+
43
+ ### Documentation
44
+
45
+ - **The README said the `/tune-context` skill needs the plugin. It doesn't.**
46
+ `tune-context init` installs the skill into `~/.claude/skills/` along with the
47
+ doctrine skills, so a plain `npm i -g` gives you both entry points (and the
48
+ plugin gives you both too, by putting `bin/tune-context` on `PATH`). Adds a
49
+ skill-vs-CLI comparison covering what a reader asks first — session, tokens,
50
+ repeatability — not just the two steps the CLI omits.
51
+
52
+ - **`--force-claude-md` is documented outside `--help` for the first time.** It is
53
+ the one flag that discards content the tool otherwise refuses to touch, so it
54
+ should be the best-documented flag rather than the least.
55
+
56
+ ## [2.2.0] — 2026-07-27
57
+
58
+ ### Changed
59
+
60
+ - **The managed block no longer holds sections you are meant to write yourself.**
61
+ `## Architecture`, `## Conventions that bite` and the one-line summary used to
62
+ sit *inside* the markers behind `<!-- fill in: … -->` prompts — so the tool
63
+ invited you to write in the one place it overwrites, and re-running it replaced
64
+ a repo's own documentation with placeholders. Measured on this repo before the
65
+ fix: 36 lines replaced, every one inside the block. Those sections are now
66
+ seeded **below** the closing marker, once, from a new
67
+ `CLAUDE.md.project.authored.tmpl`, and are never regenerated. The block keeps
68
+ only detected facts: project name, build/test/lint, MCP rules, doctrine pointer.
69
+ Affects both channels — the CLI and the `/tune-context` skill share the template.
70
+
71
+ - **`verify` no longer passes an install it did not actually check.** Deployed
72
+ hooks, agents and skills are now compared by content against the installed
73
+ package, so a copy left behind by an earlier install is reported by name.
74
+ Skills compare recursively, so an edit inside `skills/tune-context/templates/`
75
+ counts too. Agents and the `tune-context` skill had **no check at all** before.
76
+ Previously `verify` asserted only that a hook file existed and was executable
77
+ — which is how 2.1.0 shipped with the CLI reporting 2.1.0, every check green,
78
+ and the two-releases-old `context-reminder.mjs` still deployed.
79
+
80
+ ### Added
81
+
82
+ - **`tune-context verify`** — a read-only command that reports on an existing
83
+ install without touching anything. It exists because `init` *cannot* do this
84
+ job: init re-syncs before it verifies, so the drift an upgrade leaves behind
85
+ is already healed by the time init's own verify step looks. Run it after
86
+ upgrading. `init` now also names what it refreshed, instead of healing silently.
87
+ - A `?` verdict, distinct from ✓ and ✗, for checks that could not be observed —
88
+ currently "no installed package to compare against" and "nothing from this
89
+ package is deployed here" (the plugin channel). It neither passes nor fails.
90
+ - **A guard for files still in the old layout.** `init` now classifies an existing
91
+ `CLAUDE.md` before writing. If authored text sits inside the managed block, it
92
+ writes **nothing** to that file, finishes every other step (hooks, settings,
93
+ doctrine), and prints what to move and where. A block whose slots still hold the
94
+ untouched placeholders migrates silently — nothing to lose there.
95
+ - `tune-context init --force-claude-md` — regenerate a guarded file anyway,
96
+ discarding the in-block content. Opt-in only; without it, such a file could
97
+ never be migrated by the tool at all.
98
+
99
+ ### Fixed
100
+
101
+ - Migrating an old `CLAUDE.md` no longer seeds a **second, empty** copy of a
102
+ section the file already carries below the marker. The likeliest legacy shape
103
+ is a block whose slots were never filled precisely because their owner took the
104
+ advice and wrote below it; seeding on top of that lost nothing, but left a
105
+ curated file messier than it was found.
106
+
8
107
  ## [2.1.0] — 2026-07-26
9
108
 
10
109
  ### Changed
@@ -329,6 +428,11 @@ action needed on upgrade.
329
428
  (`explore-cheap`, `reviewer`), the PreCompact / SessionStart / PostToolUse hook
330
429
  suite, and the adoption report (`eval/adoption-report.mjs`).
331
430
 
431
+ [2.3.0]: https://gitlab.com/bglocation/tune-context/-/tags/v2.3.0
432
+ [2.2.0]: https://gitlab.com/bglocation/tune-context/-/tags/v2.2.0
433
+ [2.1.0]: https://gitlab.com/bglocation/tune-context/-/tags/v2.1.0
434
+ [2.0.1]: https://gitlab.com/bglocation/tune-context/-/tags/v2.0.1
435
+ [2.0.0]: https://gitlab.com/bglocation/tune-context/-/tags/v2.0.0
332
436
  [1.1.0]: https://gitlab.com/bglocation/tune-context/-/tags/v1.1.0
333
437
  [1.0.1]: https://gitlab.com/bglocation/tune-context/-/tags/v1.0.1
334
438
  [1.0.0]: https://gitlab.com/bglocation/tune-context/-/tags/v1.0.0
package/README.md CHANGED
@@ -39,6 +39,21 @@ That single command detects the stack + MCP servers, writes a lean `CLAUDE.md`,
39
39
  syncs the doctrine skills/agents/hooks into `~/.claude/`, merges hooks and
40
40
  permissions, and prints a verification report. It's idempotent — safe to re-run.
41
41
 
42
+ Safe to re-run means exactly this: the managed block in `CLAUDE.md` holds only
43
+ what detection produced and is rewritten every time; everything below the closing
44
+ marker is yours and is never touched, including the `## Architecture` and
45
+ `## Conventions that bite` sections the tool seeds there once. Write your own
46
+ notes below the marker, not inside the block.
47
+
48
+ **After upgrading the package, re-run `init`.** `npm i -g` updates the package,
49
+ not the hooks an earlier run copied into `~/.claude/hooks/` — `settings.json`
50
+ points at those copies, so a release that changes hooks goes live only after
51
+ another `init`. To check without changing anything:
52
+
53
+ ```bash
54
+ tune-context verify # read-only: is what's deployed still what ships?
55
+ ```
56
+
42
57
  Install it globally if you'll use it across repos:
43
58
 
44
59
  ```bash
@@ -46,8 +61,11 @@ npm install -g @bglocation/tune-context
46
61
  tune-context init # in any repo
47
62
  ```
48
63
 
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:
64
+ The command above also installs the **`/tune-context` skill** into
65
+ `~/.claude/skills/`, so you can type it in a Claude session straight away — it does
66
+ the same thing plus wiki wiring and richer MCP detection. Installing the plugin
67
+ instead gets you the skill without the npm install, and updates through
68
+ `/plugin marketplace update`:
51
69
 
52
70
  ```text
53
71
  /plugin marketplace add https://gitlab.com/bglocation/tune-context.git
@@ -64,11 +82,19 @@ The section below breaks down all three entry points and when to use each.
64
82
 
65
83
  Three entry points, same doctrine. Pick whichever fits how you work:
66
84
 
67
- **1. `/tune-context` skill (recommended).** With the plugin installed (below), run
68
- `/tune-context` in any repo. It detects the stack, MCP servers and existing config,
69
- then generates a lean project `CLAUDE.md`, wires the doctrine skills, and merges
70
- hooks + permissions — idempotently, never clobbering hand-written content. This is
71
- the full-fidelity path (it also handles wiki wiring and semantic-search detection).
85
+ **1. `/tune-context` skill (recommended).** Type `/tune-context` in a Claude Code
86
+ session, in the repo you want to configure. It detects the stack, MCP servers and
87
+ existing config, then generates a lean project `CLAUDE.md`, wires the doctrine
88
+ skills, and merges hooks + permissions — idempotently, never clobbering
89
+ hand-written content. This is the full-fidelity path (it also handles wiki wiring
90
+ and semantic-search detection).
91
+
92
+ **Either channel gives you the skill.** The plugin installs it, and so does
93
+ `tune-context init` — the CLI copies `skills/tune-context/` into
94
+ `~/.claude/skills/` along with the doctrine skills. So a plain `npm i -g` gives you
95
+ *both* entry points, and so does the plugin (it puts `bin/tune-context` on `PATH`
96
+ while enabled). Claude never reaches for it on its own: the skill is marked
97
+ `disable-model-invocation`, so it runs only when you type it.
72
98
 
73
99
  **2. CLI `tune-context init`.** The deterministic **subset** of the skill — same
74
100
  managed-block rules, same never-clobber guarantee, no model in the loop. Good for
@@ -84,15 +110,38 @@ node bin/tune-context.mjs init --cwd /path/to/your/repo
84
110
  It prints what it detected, what it synced/merged, and a verification report; a
85
111
  second run is a no-op.
86
112
 
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:
113
+ Two flags are worth knowing:
89
114
 
90
- | Step | Skill | CLI |
115
+ - **`--cwd <dir>`** — configure another repo instead of the current directory.
116
+ - **`--force-claude-md`** — *destructive, opt-in, and normally unnecessary.* If a
117
+ `CLAUDE.md` still has hand-written content **inside** the managed block (the
118
+ layout used before 2.2.0), `init` refuses to regenerate it and says so —
119
+ "hand-written content sits inside the managed block — regenerating would erase
120
+ it". The fix is to move that prose below the closing marker, where the tool
121
+ never reaches. This flag regenerates anyway and **discards** it; use it only
122
+ when there is nothing in there worth keeping.
123
+
124
+ ### `/tune-context` vs `tune-context init`
125
+
126
+ They are two different kinds of thing. The skill is a **procedure Claude follows**,
127
+ so it can look at your repo and decide; the CLI is **code that runs**, so it does
128
+ exactly what it was written to do, every time. Same doctrine, same managed-block
129
+ rules, same never-clobber guarantee.
130
+
131
+ | | `/tune-context` (skill) | `tune-context init` (CLI) |
91
132
  |---|---|---|
133
+ | Where you run it | Claude Code chat | terminal, incl. CI |
134
+ | Needs a Claude session | yes | no — plain Node ≥ 18 |
135
+ | Costs tokens | yes | no |
136
+ | Two runs, same repo | judgment may vary | byte-identical |
92
137
  | Project `CLAUDE.md`, doctrine skills, hooks, permissions | ✅ | ✅ |
93
138
  | Merge `.mcp.json` — add a semantic-search server if none was detected | ✅ | ❌ reads it, never writes it |
94
139
  | Wiki lever — RAG segment vs its own MCP vs a plain pointer | ✅ | ❌ not detected, not wired |
95
140
 
141
+ Both write the same block into the same files, so mixing them is safe: script the
142
+ CLI where you want repeatability, and run the skill when you want the two judgment
143
+ steps it alone can do.
144
+
96
145
  **3. Plugin install.** Load the plugin so the skills, agents and hooks are available
97
146
  in every session. The best UX is the **marketplace** — install once, get updates
98
147
  with `/plugin marketplace update`:
@@ -197,12 +246,25 @@ override's `tune-context/adoption.jsonl` — see above), and `eval/adoption-repo
197
246
  turns that into a report — code search (semantic RAG vs the Grep tool, with
198
247
  shell-plumbing greps kept out of the RAG share), doctrine skills, subagents, and
199
248
  re-read churn — closing with an *earned* verdict: withheld below a PO-frozen floor of
200
- distinct sessions, and never a false ✗ for a lever the log can't see (RAG
201
- installed-but-unused vs not installed; method-level doctrine that leaves no tool call).
202
- It's registered under two Claude Code hook events —
249
+ distinct sessions, and never a false ✗ for a lever the log can't see (method-level
250
+ doctrine leaves no tool call at all). It's registered under two Claude Code hook events —
203
251
  `PostToolUse` (every tool call) and `SubagentStart` (subagent spawns, which aren't
204
252
  regular tools).
205
253
 
254
+ **"RAG installed but ignored" vs "no RAG here"** is the one gap the log alone cannot
255
+ close, and it happens to be the finding the report most wants — so it asks the config,
256
+ per project, and only as far as the config can honestly answer. A
257
+ `~ RAG likely available, not reached for` line appears when that project's `.mcp.json`
258
+ declares a semantic-search server, no settings file rejects it, and the file is *older*
259
+ than every session logged there. Everything else stays `·` unknown: config written
260
+ during or after those sessions says nothing about them, and user-scope servers
261
+ (`~/.claude.json`) can't be dated at all, since Claude Code rewrites that file
262
+ continuously. It is never a ✗ — config names servers, not their tools, so the line
263
+ claims the lever was probably within reach, not that it existed. It also answers to
264
+ the same frozen session floor as the rest of the verdict, applied **per project**:
265
+ "available and never reached for" is a claim about absence, and one logged session
266
+ cannot establish absence.
267
+
206
268
  **Using both the plugin and `tune-context init` (CLI) on the same repo** registers
207
269
  the hook twice — once per channel — so every event logs twice. This is harmless
208
270
  (same script, same log, just doubled counts) and the report detects it: past a
@@ -29,7 +29,7 @@ import {
29
29
  mergeHooks,
30
30
  mergePermissions,
31
31
  } from '../cli/generate.mjs';
32
- import { verify } from '../cli/verify.mjs';
32
+ import { staleDoctrineEntries, verify } from '../cli/verify.mjs';
33
33
 
34
34
  const HOOK_EVENTS = [
35
35
  { event: 'PreCompact', scriptName: 'pre-compact-snapshot.mjs' },
@@ -56,9 +56,19 @@ function usage() {
56
56
  return `tune-context init — configure this repo for token-efficient Claude Code use.
57
57
 
58
58
  Usage:
59
- tune-context init [--cwd <dir>]
59
+ tune-context init [--cwd <dir>] [--force-claude-md]
60
+ tune-context verify [--cwd <dir>]
60
61
  tune-context --help
61
62
  tune-context --version
63
+
64
+ verify Check an existing install without changing anything —
65
+ including whether the deployed hooks/skills/agents still
66
+ match this package. Run it after upgrading: an upgrade
67
+ does not refresh copies an earlier init made.
68
+
69
+ --force-claude-md Regenerate a project CLAUDE.md whose hand-written content
70
+ still sits inside the managed block. Discards that content.
71
+ Without it, such a file is left untouched.
62
72
  `;
63
73
  }
64
74
 
@@ -73,14 +83,21 @@ export function parseArgs(argv) {
73
83
  // tool, not an operation on a repo, so it must not require a subcommand.
74
84
  if (argv.includes('--version') || argv.includes('-v')) return { mode: 'version' };
75
85
  const [command, ...rest] = argv;
76
- if (command !== 'init') {
86
+ // `verify` joins `init` as an accepted command (TASK-081). It writes nothing,
87
+ // which is the point: init syncs before it verifies, so init can never
88
+ // observe the drift an upgrade leaves behind — only a check that runs on its
89
+ // own can. The TASK-078 rule still holds: an unknown command fails loudly.
90
+ if (command !== 'init' && command !== 'verify') {
77
91
  return {
78
92
  mode: 'error',
79
- message: command === undefined ? 'Missing command: init' : `Unknown command: ${command}`,
93
+ message: command === undefined ? 'Missing command: init or verify' : `Unknown command: ${command}`,
80
94
  };
81
95
  }
96
+ // Opt-in only, and never inferred: this is the one flag that can destroy
97
+ // content the tool otherwise refuses to touch (TASK-082).
98
+ const forceClaudeMd = rest.includes('--force-claude-md');
82
99
  const cwdFlagIdx = rest.indexOf('--cwd');
83
- if (cwdFlagIdx === -1) return { mode: 'init', cwd: process.cwd() };
100
+ if (cwdFlagIdx === -1) return { mode: command, cwd: process.cwd(), forceClaudeMd };
84
101
  const value = rest[cwdFlagIdx + 1];
85
102
  // Undefined (last arg) or another flag (e.g. `--cwd --foo`) both mean "no
86
103
  // value was actually given" — resolve(undefined) used to throw a raw
@@ -88,10 +105,10 @@ export function parseArgs(argv) {
88
105
  if (value === undefined || value.startsWith('-')) {
89
106
  return { mode: 'error', message: 'Missing value for --cwd' };
90
107
  }
91
- return { mode: 'init', cwd: resolve(value) };
108
+ return { mode: command, cwd: resolve(value), forceClaudeMd };
92
109
  }
93
110
 
94
- function runInit(cwd) {
111
+ function runInit(cwd, { forceClaudeMd = false } = {}) {
95
112
  const templatesDir = join(pkgRoot, 'skills', 'tune-context', 'templates');
96
113
  const { claudeDir } = claudeDirs();
97
114
 
@@ -115,9 +132,16 @@ function runInit(cwd) {
115
132
 
116
133
  const detection = { stack, mcpServers };
117
134
 
135
+ // Sample staleness BEFORE syncing. init's own sync heals drift, so by the
136
+ // time verify runs at the end there is nothing left to find — the whole
137
+ // reason `tune-context verify` exists as a command of its own (TASK-081).
138
+ // What init can still do, and what an upgrading user needs to hear, is name
139
+ // what WAS stale and has just been refreshed.
140
+ const wasStale = staleDoctrineEntries(claudeDir, pkgRoot)?.stale ?? [];
141
+
118
142
  const synced = installDoctrine(pkgRoot, claudeDir);
119
143
  const userClaudeMd = generateUserClaudeMd(claudeDir, templatesDir);
120
- const projectClaudeMd = generateProjectClaudeMd(cwd, detection, templatesDir);
144
+ const projectClaudeMd = generateProjectClaudeMd(cwd, detection, templatesDir, { force: forceClaudeMd });
121
145
 
122
146
  const settingsTemplate = loadSettingsTemplate(templatesDir);
123
147
  const hooksAdded = mergeHooks(existing.userSettingsJson, { hooks: settingsTemplate.hooks });
@@ -130,6 +154,7 @@ function runInit(cwd) {
130
154
  projectSettingsPath: existing.projectSettingsJson,
131
155
  expectedHookCommands: HOOK_EVENTS,
132
156
  expectedPermissions: permissionsAllow,
157
+ pkgRoot,
133
158
  });
134
159
 
135
160
  console.log(`tune-context init — ${cwd}\n`);
@@ -138,8 +163,20 @@ function runInit(cwd) {
138
163
  console.log(' build/test/lint commands not detected for this ecosystem — filled with TODO placeholders in CLAUDE.md.');
139
164
  }
140
165
  console.log(`\nDoctrine synced to ${claudeDir}: ${synced.length} item(s)`);
166
+ if (wasStale.length) {
167
+ const noun = wasStale.length === 1 ? 'copy' : 'copies';
168
+ console.log(` refreshed ${wasStale.length} stale ${noun} left behind by an earlier install: ${wasStale.join(', ')}`);
169
+ }
141
170
  console.log(`User CLAUDE.md: ${userClaudeMd.changed ? 'written' : `skipped (${userClaudeMd.skipped})`}`);
142
- console.log(`Project CLAUDE.md: ${projectClaudeMd.changed ? `updated ${projectClaudeMd.path}` : 'already up to date'}`);
171
+ if (projectClaudeMd.guarded) {
172
+ console.log(`Project CLAUDE.md: SKIPPED — ${projectClaudeMd.skipped}`);
173
+ } else {
174
+ // migrated is checked first: a legacy file whose owner already wrote their
175
+ // own sections below the marker migrates without being seeded, so `seeded`
176
+ // alone would report that run as a plain "updated".
177
+ const how = projectClaudeMd.migrated ? 'migrated to the new layout' : projectClaudeMd.seeded ? 'created' : 'updated';
178
+ console.log(`Project CLAUDE.md: ${projectClaudeMd.changed ? `${how} ${projectClaudeMd.path}` : 'already up to date'}`);
179
+ }
143
180
  console.log(`Hooks merged into ${existing.userSettingsJson}: ${hooksAdded.length ? hooksAdded.join(', ') : 'already present'}`);
144
181
  console.log(`Permissions merged into ${existing.projectSettingsJson}: ${permissionsAdded.length ? permissionsAdded.join(', ') : 'already present'}`);
145
182
  console.log(`MCP servers detected: ${mcpServers.length ? mcpServers.map((s) => `${s.name} (${s.scope}, ${s.classification})`).join(', ') : 'none'}`);
@@ -148,13 +185,71 @@ function runInit(cwd) {
148
185
  console.log(` skipped (rejected via disabledMcpjsonServers): ${rejectedServers.map((s) => s.name).join(', ')} — no permission rule, no CLAUDE.md line`);
149
186
  }
150
187
 
188
+ // Loud, and separated from the one-line summary: this is the only step that
189
+ // finished with nothing written, and the user has to act for it to land.
190
+ if (projectClaudeMd.guarded) {
191
+ console.log(`
192
+ ! ${projectClaudeMd.path} was NOT written.
193
+ It predates the current layout: "## Architecture" / "## Conventions that bite"
194
+ sit INSIDE the managed markers, where every run regenerates. Your text is
195
+ still there and untouched.
196
+ To migrate: move those sections BELOW the "${'<!-- <<< tune-context (managed) <<< -->'}"
197
+ line, then re-run init — everything outside the markers is never touched again.
198
+ To regenerate anyway and lose them: tune-context init --force-claude-md`);
199
+ }
200
+
151
201
  console.log(`\nVerify: ${result.ok ? 'OK' : 'FAILED'}`);
152
202
  for (const check of result.checks) {
153
- console.log(` ${check.ok ? '✓' : '✗'} ${check.name}${check.detail ? ` — ${check.detail}` : ''}`);
203
+ // `?` is its own verdict: something we could not observe, reported as
204
+ // neither verified nor broken (TASK-081).
205
+ const mark = check.unknown ? '?' : check.ok ? '✓' : '✗';
206
+ console.log(` ${mark} ${check.name}${check.detail ? ` — ${check.detail}` : ''}`);
154
207
  }
208
+
209
+ // Said on every run, not only when drift is found: the moment a user learns
210
+ // this is the moment they upgrade, and by then a stale hook is already live.
211
+ console.log(`\nNote: upgrading the package does not refresh hooks already installed in ${join(claudeDir, 'hooks')}.
212
+ Re-run \`tune-context init\` after any release that changes hooks/ — the staleness check above is what catches it if you don't.`);
213
+
155
214
  if (!result.ok) process.exitCode = 1;
156
215
  }
157
216
 
217
+ /**
218
+ * Read-only counterpart to init: report on an install without touching a single
219
+ * file. Detection still runs, because the expected permissions depend on the
220
+ * project's own stack — but nothing is written, so this is safe to run anywhere,
221
+ * any number of times, including from CI after an upgrade.
222
+ */
223
+ function runVerify(cwd) {
224
+ const { claudeDir } = claudeDirs();
225
+ const existing = detectExistingConfig(cwd);
226
+ const detection = { stack: detectStack(cwd), mcpServers: detectMcpServers(cwd) };
227
+ const permissionsAllow = buildPermissionsAllow(detection);
228
+
229
+ const result = verify({
230
+ claudeDir,
231
+ userSettingsPath: existing.userSettingsJson,
232
+ projectSettingsPath: existing.projectSettingsJson,
233
+ expectedHookCommands: HOOK_EVENTS,
234
+ expectedPermissions: permissionsAllow,
235
+ pkgRoot,
236
+ });
237
+
238
+ console.log(`tune-context verify — ${cwd}\n`);
239
+ console.log(`Verify: ${result.ok ? 'OK' : 'FAILED'}`);
240
+ for (const check of result.checks) {
241
+ const mark = check.unknown ? '?' : check.ok ? '✓' : '✗';
242
+ console.log(` ${mark} ${check.name}${check.detail ? ` — ${check.detail}` : ''}`);
243
+ }
244
+ // A read-only command owes the reader the next step. Run on a machine that
245
+ // never ran init, this prints a screen of ✗ and, without the line below,
246
+ // nothing that says the whole list is one command away from green.
247
+ if (!result.ok) {
248
+ console.log('\nNext: `tune-context init` writes what is missing and refreshes what drifted. It never edits content outside the managed block.');
249
+ process.exitCode = 1;
250
+ }
251
+ }
252
+
158
253
  export function main() {
159
254
  const parsed = parseArgs(process.argv.slice(2));
160
255
  if (parsed.mode === 'help') {
@@ -168,8 +263,10 @@ export function main() {
168
263
  // touched on an invalid invocation (TASK-078).
169
264
  process.stderr.write(`${parsed.message}\n\n${usage()}`);
170
265
  process.exitCode = 1;
266
+ } else if (parsed.mode === 'verify') {
267
+ runVerify(parsed.cwd);
171
268
  } else {
172
- runInit(parsed.cwd);
269
+ runInit(parsed.cwd, { forceClaudeMd: parsed.forceClaudeMd });
173
270
  }
174
271
  }
175
272
 
package/cli/generate.mjs CHANGED
@@ -5,7 +5,37 @@
5
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
6
  import { dirname, join } from 'node:path';
7
7
  import { syncDoctrine } from './sync-doctrine.mjs';
8
- import { upsertManagedBlock } from './managed-block.mjs';
8
+ import { END_MARKER, START_MARKER, upsertManagedBlock } from './managed-block.mjs';
9
+
10
+ /**
11
+ * Headings the PRE-TASK-082 template kept INSIDE the managed block, together
12
+ * with the exact placeholders the CLI wrote into them (old generate.mjs slots
13
+ * ONE_LINE_WHAT_THIS_IS / ARCHITECTURE_BULLETS / CONVENTIONS).
14
+ *
15
+ * They exist here for exactly one reason: recognising a file written by an
16
+ * older version. The current template emits neither, so "does the block still
17
+ * contain them" is a precise test for the old layout — no heuristics, no
18
+ * guessing whether prose "looks hand-written".
19
+ *
20
+ * This is the one place that deliberately encodes knowledge of a previous
21
+ * template. Deleting it would not break generation; it would break the guard
22
+ * that stops a regeneration from erasing someone's documentation.
23
+ */
24
+ const LEGACY_AUTHORED_HEADINGS = ['## Architecture', '## Conventions that bite'];
25
+ const LEGACY_PLACEHOLDERS = [
26
+ '<!-- fill in: one sentence, what this project is -->',
27
+ '<!-- fill in: 3-6 lines, module -> responsibility -->',
28
+ '<!-- fill in: only rules that bite -->',
29
+ ];
30
+
31
+ /**
32
+ * The headings the seed would introduce. If the file already carries one of them
33
+ * OUTSIDE the block it already has an authored half, and seeding would staple a
34
+ * second, empty copy above the one its owner actually wrote. A legacy file whose
35
+ * in-block slots were never filled *because* the owner wrote below the marker is
36
+ * exactly that case, and it is the likeliest one: they followed the advice.
37
+ */
38
+ const SEEDED_HEADINGS = ['## What this is', ...LEGACY_AUTHORED_HEADINGS];
9
39
 
10
40
  /**
11
41
  * The hooks directory baked into settings.json commands.
@@ -86,9 +116,105 @@ function templateInnerBlock(templatePath) {
86
116
  return raw.slice(startLineEnd, end).trimEnd();
87
117
  }
88
118
 
89
- export function generateProjectClaudeMd(cwd, detection, templatesDir) {
90
- const templatePath = join(templatesDir, 'CLAUDE.md.project.tmpl');
91
- const inner = templateInnerBlock(templatePath);
119
+ /**
120
+ * Which layout an existing project CLAUDE.md is in — the whole guard rests on
121
+ * this one classification.
122
+ *
123
+ * @param {string|null} existing - file content, or null if there is no file
124
+ * @returns {'absent'|'no-block'|'current'|'legacy-empty'|'legacy-authored'}
125
+ *
126
+ * absent - nothing there yet; create block + seed the authored part
127
+ * no-block - a hand-written CLAUDE.md with no markers; appending our
128
+ * block cannot touch what is already there
129
+ * current - post-TASK-082 layout; regenerate the block, nothing else
130
+ * legacy-empty - old layout, the authored slots still hold their untouched
131
+ * placeholders; migrating loses nothing, so do it silently
132
+ * legacy-authored - old layout with real content inside the block; the ONLY
133
+ * case that must not be overwritten
134
+ *
135
+ * "All three placeholders still present verbatim" is the test for legacy-empty,
136
+ * so any deviation — filled in, reworded, or a section deleted — classifies as
137
+ * authored. The asymmetry is deliberate: a needless guard costs one message, a
138
+ * missed one costs somebody's documentation.
139
+ */
140
+ export function classifyProjectClaudeMd(existing) {
141
+ if (existing == null) return 'absent';
142
+ const start = existing.indexOf(START_MARKER);
143
+ const end = existing.indexOf(END_MARKER);
144
+ if (start === -1 || end === -1 || end < start) return 'no-block';
145
+ const block = existing.slice(start, end);
146
+ if (!LEGACY_AUTHORED_HEADINGS.some((heading) => block.includes(heading))) return 'current';
147
+ return LEGACY_PLACEHOLDERS.every((placeholder) => block.includes(placeholder)) ? 'legacy-empty' : 'legacy-authored';
148
+ }
149
+
150
+ /**
151
+ * The seeded half of the project CLAUDE.md. A bare ENOENT here would surface as
152
+ * a Node stacktrace mid-init; the same reasoning as templateInnerBlock's marker
153
+ * check — a broken install must say what is broken, not fail cryptically or,
154
+ * worse, quietly skip the half of the file the user is meant to write in.
155
+ */
156
+ function readAuthoredTemplate(templatesDir) {
157
+ const path = join(templatesDir, 'CLAUDE.md.project.authored.tmpl');
158
+ if (!existsSync(path)) throw new Error(`missing template ${path} — the authored half of CLAUDE.md cannot be seeded`);
159
+ return readFileSync(path, 'utf8');
160
+ }
161
+
162
+ /**
163
+ * Put the seeded authored skeleton directly under the managed block, so the
164
+ * generated document reads top-to-bottom (derived facts, then your own notes)
165
+ * instead of appending it after whatever else the file already holds.
166
+ */
167
+ function hasAuthoredSectionsOutsideBlock(content) {
168
+ const start = content.indexOf(START_MARKER);
169
+ const end = content.indexOf(END_MARKER);
170
+ const outside =
171
+ start === -1 || end === -1 || end < start ? content : content.slice(0, start) + content.slice(end + END_MARKER.length);
172
+ return SEEDED_HEADINGS.some((heading) => outside.includes(heading));
173
+ }
174
+
175
+ function insertAuthoredSeed(content, seed) {
176
+ const idx = content.indexOf(END_MARKER);
177
+ const body = seed.trim();
178
+ if (idx === -1) return `${content.trimEnd()}\n\n${body}\n`;
179
+ const cut = idx + END_MARKER.length;
180
+ const tail = content.slice(cut).replace(/^\n+/, '');
181
+ return tail ? `${content.slice(0, cut)}\n\n${body}\n\n${tail}` : `${content.slice(0, cut)}\n\n${body}\n`;
182
+ }
183
+
184
+ /**
185
+ * Write/refresh the project CLAUDE.md.
186
+ *
187
+ * The managed block carries ONLY what detection produced. Sections a human
188
+ * writes (what this is, architecture, conventions) are seeded once, BELOW the
189
+ * block, and never rewritten — the same never-clobber guarantee this module
190
+ * already gives ~/.claude/CLAUDE.md and settings.json. Before TASK-082 those
191
+ * sections lived inside the block with "fill in" prompts, i.e. the tool invited
192
+ * you to write exactly where it overwrites.
193
+ *
194
+ * @param {{force?: boolean}} [options] - force:true regenerates a legacy-authored
195
+ * file anyway, discarding the in-block content. Only ever set from an explicit
196
+ * `--force-claude-md`, never by default: without an escape hatch a file that
197
+ * trips the guard could never be migrated by the tool at all.
198
+ * @returns {{path, changed, layout, guarded?, seeded?, migrated?, skipped?}}
199
+ */
200
+ export function generateProjectClaudeMd(cwd, detection, templatesDir, { force = false } = {}) {
201
+ const path = join(cwd, 'CLAUDE.md');
202
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : null;
203
+ const layout = classifyProjectClaudeMd(existing);
204
+
205
+ // Guard BEFORE any write. A warning printed after the fact would be worthless:
206
+ // the content it warns about would already be gone.
207
+ if (layout === 'legacy-authored' && !force) {
208
+ return {
209
+ path,
210
+ changed: false,
211
+ layout,
212
+ guarded: true,
213
+ skipped: 'hand-written content sits inside the managed block — regenerating would erase it',
214
+ };
215
+ }
216
+
217
+ const inner = templateInnerBlock(join(templatesDir, 'CLAUDE.md.project.tmpl'));
92
218
 
93
219
  const mcpRules = detection.mcpServers.length
94
220
  ? detection.mcpServers.map((s) => `- \`${s.name}\` (${s.scope}, ${s.classification}) — see token-efficiency skill for the matching rule.`).join('\n')
@@ -96,21 +222,29 @@ export function generateProjectClaudeMd(cwd, detection, templatesDir) {
96
222
 
97
223
  const filled = fillTemplate(inner, {
98
224
  PROJECT_NAME: detection.stack.name || '<!-- fill in: project name -->',
99
- ONE_LINE_WHAT_THIS_IS: '<!-- fill in: one sentence, what this project is -->',
100
225
  BUILD_CMD: detection.stack.buildCmd || '# no build script detected — fill in or delete this line',
101
226
  TEST_CMD: detection.stack.testCmd || '# no test script detected — fill in or delete this line',
102
227
  LINT_CMD: detection.stack.lintCmd || '# no lint script detected — fill in or delete this line',
103
228
  RUN_NOTES: detection.stack.monorepo ? '<!-- monorepo detected — note which package/workspace to run commands from -->' : '',
104
229
  MCP_RULES: mcpRules,
105
- ARCHITECTURE_BULLETS: '<!-- fill in: 3-6 lines, module -> responsibility -->',
106
- CONVENTIONS: '<!-- fill in: only rules that bite -->',
107
230
  });
108
231
 
109
- const path = join(cwd, 'CLAUDE.md');
110
- const existing = existsSync(path) ? readFileSync(path, 'utf8') : null;
111
- const { content, changed } = upsertManagedBlock(existing, filled);
112
- if (changed) writeFileSync(path, content);
113
- return { path, changed };
232
+ const { content } = upsertManagedBlock(existing, filled);
233
+
234
+ // Seed exactly when the authored sections have nowhere else to live: a brand
235
+ // new file, or an old one whose never-filled sections vanish with the block
236
+ // they sat in — and, either way, only when the file does not already have an
237
+ // authored half of its own below the marker. Never on a `current` file: the
238
+ // user may have deleted those headings on purpose, and re-adding them would be
239
+ // its own kind of clobber.
240
+ const seeded =
241
+ (layout === 'absent' || layout === 'legacy-empty' || (force && layout === 'legacy-authored')) &&
242
+ !hasAuthoredSectionsOutsideBlock(content);
243
+ const next = seeded ? insertAuthoredSeed(content, readAuthoredTemplate(templatesDir)) : content;
244
+
245
+ const changed = existing === null || next !== existing;
246
+ if (changed) writeFileSync(path, next);
247
+ return { path, changed, layout, seeded, migrated: layout === 'legacy-empty' };
114
248
  }
115
249
 
116
250
  export function generateUserClaudeMd(claudeDir, templatesDir) {