@bglocation/tune-context 2.1.0 → 2.2.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.2.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.2.0",
6
6
  "author": {
7
7
  "name": "Szymon Walczak",
8
8
  "url": "https://bglocation.dev"
package/CHANGELOG.md CHANGED
@@ -5,6 +5,57 @@ 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.2.0] — 2026-07-27
9
+
10
+ ### Changed
11
+
12
+ - **The managed block no longer holds sections you are meant to write yourself.**
13
+ `## Architecture`, `## Conventions that bite` and the one-line summary used to
14
+ sit *inside* the markers behind `<!-- fill in: … -->` prompts — so the tool
15
+ invited you to write in the one place it overwrites, and re-running it replaced
16
+ a repo's own documentation with placeholders. Measured on this repo before the
17
+ fix: 36 lines replaced, every one inside the block. Those sections are now
18
+ seeded **below** the closing marker, once, from a new
19
+ `CLAUDE.md.project.authored.tmpl`, and are never regenerated. The block keeps
20
+ only detected facts: project name, build/test/lint, MCP rules, doctrine pointer.
21
+ Affects both channels — the CLI and the `/tune-context` skill share the template.
22
+
23
+ - **`verify` no longer passes an install it did not actually check.** Deployed
24
+ hooks, agents and skills are now compared by content against the installed
25
+ package, so a copy left behind by an earlier install is reported by name.
26
+ Skills compare recursively, so an edit inside `skills/tune-context/templates/`
27
+ counts too. Agents and the `tune-context` skill had **no check at all** before.
28
+ Previously `verify` asserted only that a hook file existed and was executable
29
+ — which is how 2.1.0 shipped with the CLI reporting 2.1.0, every check green,
30
+ and the two-releases-old `context-reminder.mjs` still deployed.
31
+
32
+ ### Added
33
+
34
+ - **`tune-context verify`** — a read-only command that reports on an existing
35
+ install without touching anything. It exists because `init` *cannot* do this
36
+ job: init re-syncs before it verifies, so the drift an upgrade leaves behind
37
+ is already healed by the time init's own verify step looks. Run it after
38
+ upgrading. `init` now also names what it refreshed, instead of healing silently.
39
+ - A `?` verdict, distinct from ✓ and ✗, for checks that could not be observed —
40
+ currently "no installed package to compare against" and "nothing from this
41
+ package is deployed here" (the plugin channel). It neither passes nor fails.
42
+ - **A guard for files still in the old layout.** `init` now classifies an existing
43
+ `CLAUDE.md` before writing. If authored text sits inside the managed block, it
44
+ writes **nothing** to that file, finishes every other step (hooks, settings,
45
+ doctrine), and prints what to move and where. A block whose slots still hold the
46
+ untouched placeholders migrates silently — nothing to lose there.
47
+ - `tune-context init --force-claude-md` — regenerate a guarded file anyway,
48
+ discarding the in-block content. Opt-in only; without it, such a file could
49
+ never be migrated by the tool at all.
50
+
51
+ ### Fixed
52
+
53
+ - Migrating an old `CLAUDE.md` no longer seeds a **second, empty** copy of a
54
+ section the file already carries below the marker. The likeliest legacy shape
55
+ is a block whose slots were never filled precisely because their owner took the
56
+ advice and wrote below it; seeding on top of that lost nothing, but left a
57
+ curated file messier than it was found.
58
+
8
59
  ## [2.1.0] — 2026-07-26
9
60
 
10
61
  ### Changed
@@ -329,6 +380,10 @@ action needed on upgrade.
329
380
  (`explore-cheap`, `reviewer`), the PreCompact / SessionStart / PostToolUse hook
330
381
  suite, and the adoption report (`eval/adoption-report.mjs`).
331
382
 
383
+ [2.2.0]: https://gitlab.com/bglocation/tune-context/-/tags/v2.2.0
384
+ [2.1.0]: https://gitlab.com/bglocation/tune-context/-/tags/v2.1.0
385
+ [2.0.1]: https://gitlab.com/bglocation/tune-context/-/tags/v2.0.1
386
+ [2.0.0]: https://gitlab.com/bglocation/tune-context/-/tags/v2.0.0
332
387
  [1.1.0]: https://gitlab.com/bglocation/tune-context/-/tags/v1.1.0
333
388
  [1.0.1]: https://gitlab.com/bglocation/tune-context/-/tags/v1.0.1
334
389
  [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
@@ -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) {
@@ -14,25 +14,42 @@ import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, rmSync } from 'n
14
14
  import { dirname, join } from 'node:path';
15
15
 
16
16
  /**
17
+ * Everything this package deploys into a Claude config dir, as
18
+ * `{kind, name, source}` — one enumeration, used both by the copier below and
19
+ * by verify's staleness check (TASK-081). Two lists would drift apart, and a
20
+ * freshness check that walks a *different* set than the sync did would go
21
+ * green on exactly the file nobody thought to add to it.
22
+ *
17
23
  * @param {string} pkgRoot - root of the tune-context package (contains skills/, agents/, hooks/)
18
- * @param {string} claudeDir - destination Claude config dir (e.g. ~/.claude)
19
- * @returns {{kind: string, name: string, dest: string}[]} synced items
24
+ * @returns {{kind: string, name: string, source: string}[]}
20
25
  */
21
- export function syncDoctrine(pkgRoot, claudeDir) {
22
- const synced = [];
26
+ export function doctrineEntries(pkgRoot) {
27
+ const entries = [];
23
28
  for (const kind of ['skills', 'agents', 'hooks']) {
24
29
  const from = join(pkgRoot, kind);
25
30
  if (!existsSync(from)) continue;
26
31
  for (const entry of readdirSync(from)) {
27
32
  if (kind === 'hooks' && entry === 'hooks.json') continue; // plugin-only manifest, not a script to install
28
- const s = join(from, entry);
29
- const d = join(claudeDir, kind, entry);
30
- rmSync(d, { recursive: true, force: true });
31
- mkdirSync(dirname(d), { recursive: true });
32
- cpSync(s, d, { recursive: true });
33
- if (kind === 'hooks') chmodSync(d, 0o755);
34
- synced.push({ kind, name: entry, dest: d });
33
+ entries.push({ kind, name: entry, source: join(from, entry) });
35
34
  }
36
35
  }
36
+ return entries;
37
+ }
38
+
39
+ /**
40
+ * @param {string} pkgRoot - root of the tune-context package (contains skills/, agents/, hooks/)
41
+ * @param {string} claudeDir - destination Claude config dir (e.g. ~/.claude)
42
+ * @returns {{kind: string, name: string, dest: string}[]} synced items
43
+ */
44
+ export function syncDoctrine(pkgRoot, claudeDir) {
45
+ const synced = [];
46
+ for (const { kind, name, source } of doctrineEntries(pkgRoot)) {
47
+ const d = join(claudeDir, kind, name);
48
+ rmSync(d, { recursive: true, force: true });
49
+ mkdirSync(dirname(d), { recursive: true });
50
+ cpSync(source, d, { recursive: true });
51
+ if (kind === 'hooks') chmodSync(d, 0o755);
52
+ synced.push({ kind, name, dest: d });
53
+ }
37
54
  return synced;
38
55
  }
package/cli/verify.mjs CHANGED
@@ -1,9 +1,43 @@
1
1
  // Re-checks what generate.mjs claims to have done — "the merge actually
2
2
  // landed" per SKILL.md §3. Never trust that a write succeeded just because
3
3
  // the function returned; re-read the target file and parse it.
4
- import { existsSync, readFileSync, statSync } from 'node:fs';
4
+ import { createHash } from 'node:crypto';
5
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
5
6
  import { homedir } from 'node:os';
6
7
  import { basename, join, resolve } from 'node:path';
8
+ import { doctrineEntries } from './sync-doctrine.mjs';
9
+
10
+ /**
11
+ * Content fingerprint of a deployed artifact — a file, or a whole directory
12
+ * (skills are directories; `skills/tune-context` carries its own templates/).
13
+ *
14
+ * A directory hashes as the sorted list of `relative path + content hash`, so a
15
+ * file MISSING from inside a synced skill, or an extra one left behind by an
16
+ * older shape, is drift too — not just an edited byte. Sorting is what makes it
17
+ * reproducible across filesystems that hand back readdir in different orders.
18
+ *
19
+ * Mode is deliberately not hashed: syncDoctrine chmods hooks to 755 after
20
+ * copying, so folding the exec bit in here would report a rewrite as drift on
21
+ * every platform where the package's own bits differ. The exec bit already has
22
+ * its own check.
23
+ *
24
+ * @returns {string|null} null when the path does not exist
25
+ */
26
+ export function fingerprint(path) {
27
+ if (!existsSync(path)) return null;
28
+ const hash = createHash('sha256');
29
+ const walk = (current, rel) => {
30
+ const stat = statSync(current);
31
+ if (stat.isDirectory()) {
32
+ for (const entry of readdirSync(current).sort()) walk(join(current, entry), rel ? `${rel}/${entry}` : entry);
33
+ return;
34
+ }
35
+ hash.update(`${rel}\0`);
36
+ hash.update(createHash('sha256').update(readFileSync(current)).digest());
37
+ };
38
+ walk(path, '');
39
+ return hash.digest('hex');
40
+ }
7
41
 
8
42
  function readJson(path) {
9
43
  try {
@@ -50,9 +84,83 @@ function expandHome(p) {
50
84
  const PLUGIN_PATH_HINT = /\$\{CLAUDE_PLUGIN_ROOT\}|[\\/]plugins[\\/]/;
51
85
 
52
86
  /**
53
- * @returns {{ok: boolean, checks: {name: string, ok: boolean, detail?: string}[]}}
87
+ * Are the deployed copies still the ones this package ships? (TASK-081)
88
+ *
89
+ * `npm i -g` updates the package; it does NOT touch what an earlier `init`
90
+ * copied into ~/.claude. settings.json points at those copies, so a release
91
+ * that changes hooks/ goes live only after another `init`. Observed right after
92
+ * publishing 2.1.0: the CLI reported 2.1.0, verify was all green, and the
93
+ * deployed context-reminder.mjs was still the 2.0.x file — the *only* file the
94
+ * release had changed.
95
+ *
96
+ * Two rules keep this from crying wolf:
97
+ *
98
+ * - **Only what is actually deployed is compared.** An artifact absent from
99
+ * claudeDir is not reported here at all, which is what makes a plugin-channel
100
+ * install (registrations pointing at `${CLAUDE_PLUGIN_ROOT}`, no copies
101
+ * anywhere) impossible to false-red: there is nothing to compare, so nothing
102
+ * is claimed. Presence of the things that MUST exist is a separate check.
103
+ * - **No package to compare against means "not checked", never a tick.** A
104
+ * caller that cannot say where the installed package lives gets `unknown`,
105
+ * which prints as `?` and neither passes nor fails — the TASK-070 rule that
106
+ * an unobservable thing must not be reported as verified.
107
+ *
108
+ * @param {string|null} pkgRoot - root of the installed package, or null/undefined
109
+ * @returns {{name: string, ok: boolean, detail?: string, unknown?: boolean}[]}
54
110
  */
55
- export function verify({ claudeDir, userSettingsPath, expectedHookCommands, projectSettingsPath, expectedPermissions }) {
111
+ export function staleDoctrineEntries(claudeDir, pkgRoot) {
112
+ if (!pkgRoot || !existsSync(pkgRoot)) return null;
113
+ const stale = [];
114
+ let compared = 0;
115
+ for (const { kind, name, source } of doctrineEntries(pkgRoot)) {
116
+ const deployed = join(claudeDir, kind, name);
117
+ const deployedPrint = fingerprint(deployed);
118
+ if (deployedPrint === null) continue; // not deployed through this channel — not ours to judge
119
+ compared += 1;
120
+ if (deployedPrint !== fingerprint(source)) stale.push(`${kind}/${name}`);
121
+ }
122
+ return { compared, stale };
123
+ }
124
+
125
+ function freshnessChecks(claudeDir, pkgRoot) {
126
+ const result = staleDoctrineEntries(claudeDir, pkgRoot);
127
+ if (result === null) {
128
+ return [
129
+ {
130
+ name: 'deployed skills/agents/hooks match the installed package',
131
+ ok: false,
132
+ unknown: true,
133
+ detail: 'not checked — no installed package to compare against; re-run `tune-context init` if a release changed hooks/',
134
+ },
135
+ ];
136
+ }
137
+
138
+ const { compared, stale } = result;
139
+ if (compared === 0) {
140
+ return [
141
+ {
142
+ name: 'deployed skills/agents/hooks match the installed package',
143
+ ok: false,
144
+ unknown: true,
145
+ detail: `not checked — nothing from this package is deployed under ${claudeDir}`,
146
+ },
147
+ ];
148
+ }
149
+
150
+ return [
151
+ {
152
+ name: `deployed skills/agents/hooks match the installed package (${compared} compared)`,
153
+ ok: stale.length === 0,
154
+ detail: stale.length ? `stale: ${stale.join(', ')} — content differs from the installed package; re-run \`tune-context init\`` : undefined,
155
+ },
156
+ ];
157
+ }
158
+
159
+ /**
160
+ * @param {string} [pkgRoot] - installed package root, for the staleness check
161
+ * @returns {{ok: boolean, checks: {name: string, ok: boolean, detail?: string, unknown?: boolean}[]}}
162
+ */
163
+ export function verify({ claudeDir, userSettingsPath, expectedHookCommands, projectSettingsPath, expectedPermissions, pkgRoot }) {
56
164
  const checks = [];
57
165
 
58
166
  // Cross-check, not two independent presence checks. The old code asked
@@ -164,5 +272,9 @@ export function verify({ claudeDir, userSettingsPath, expectedHookCommands, proj
164
272
  checks.push({ name: `skill ${skill} installed`, ok, detail: ok ? undefined : `missing ${join(skillsDir, skill, 'SKILL.md')}` });
165
273
  }
166
274
 
167
- return { ok: checks.every((c) => c.ok), checks };
275
+ checks.push(...freshnessChecks(claudeDir, pkgRoot));
276
+
277
+ // `unknown` is neither a pass nor a failure: it must not turn a healthy
278
+ // install red, and it must not be counted as verified either.
279
+ return { ok: checks.every((c) => c.ok || c.unknown === true), checks };
168
280
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bglocation/tune-context",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Context-efficiency configurator for Claude Code \u2014 token-saving doctrine packaged as skills, plus a tune-context configurator that scaffolds a lean CLAUDE.md, doctrine skills, subagents, settings, hooks and MCP wiring.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,6 +22,17 @@ than rewriting the file. For JSON (`.mcp.json`, `settings.json`) there are no
22
22
  markers — merge key-by-key and preserve every existing key. This is what makes a
23
23
  second run a no-op.
24
24
 
25
+ **Only derived content goes inside the block.** The block carries what detection
26
+ produced — project name, build/test/lint, MCP rules, the doctrine pointer. The
27
+ sections a human writes (what this is, architecture, conventions) are seeded
28
+ **below** the closing marker from `CLAUDE.md.project.authored.tmpl`, once, and
29
+ are never regenerated. Before TASK-082 they sat inside the block behind "fill in"
30
+ prompts, i.e. the tool asked the user to write in the one place it overwrites;
31
+ re-running it replaced a repo's architecture notes with placeholders. If you meet
32
+ a CLAUDE.md still in that shape — authored text inside the markers — **do not
33
+ regenerate it**: move those sections below the closing marker first (the CLI
34
+ refuses outright and prints the same instruction).
35
+
25
36
  **Delta only, never duplicate.** If the existing hand-written CLAUDE.md already
26
37
  covers the template's content (build/test/lint, architecture, conventions), the
27
38
  managed block carries **only what is missing** — usually just pointers to the
@@ -98,10 +109,13 @@ covering the detected build/test/lint commands.
98
109
 
99
110
  ## 2. Generate (idempotent, never clobber)
100
111
 
101
- 1. **Project `./CLAUDE.md`** from `${CLAUDE_SKILL_DIR}/templates/CLAUDE.md.project.tmpl`.
102
- Fill the slots from detection; delete the slot guide comment; keep the managed
103
- markers. If the file exists, replace only the managed block — hand-written
104
- content outside it survives untouched.
112
+ 1. **Project `./CLAUDE.md`** from `${CLAUDE_SKILL_DIR}/templates/CLAUDE.md.project.tmpl`
113
+ (managed block) plus `CLAUDE.md.project.authored.tmpl` (the half below it).
114
+ Fill the block's slots from detection; delete the slot guide comment; keep the
115
+ managed markers. Seed the authored half **only when creating the file** — if
116
+ the file exists, replace the managed block and nothing else. Never write
117
+ detected facts below the marker, and never write authored prose above it: the
118
+ split is what makes re-running safe.
105
119
  2. **User `~/.claude/CLAUDE.md`** from `${CLAUDE_SKILL_DIR}/templates/CLAUDE.md.user.tmpl`
106
120
  — only if it is missing or clearly untuned. Never overwrite a user-tuned file
107
121
  without asking.
@@ -0,0 +1,16 @@
1
+ <!-- Everything below is yours. tune-context seeds these headings once, when it
2
+ first creates this file, and never rewrites them: re-running the tool
3
+ replaces only the managed block above. Delete whatever you don't need.
4
+ Keep it thin — headings and pointers here, depth in skills. -->
5
+
6
+ ## What this is
7
+
8
+ <!-- fill in: one sentence, what this project is -->
9
+
10
+ ## Architecture
11
+
12
+ <!-- fill in: 3-6 lines, module -> responsibility -->
13
+
14
+ ## Conventions that bite
15
+
16
+ <!-- fill in: only rules that bite (things that break if ignored) -->
@@ -1,10 +1,10 @@
1
1
  <!-- >>> tune-context (managed) >>>
2
- Regenerated by /tune-context. Edit freely OUTSIDE this block — content
3
- outside the markers is never touched. -->
2
+ Regenerated by /tune-context and `tune-context init`. Everything between
3
+ these markers is DERIVED from detection and is rewritten on every run —
4
+ never hand-edit it. Your own content goes BELOW the block, where the tool
5
+ never reaches. -->
4
6
  # CLAUDE.md — {{PROJECT_NAME}}
5
7
 
6
- {{ONE_LINE_WHAT_THIS_IS}}
7
-
8
8
  ## Build / test / lint
9
9
  ```bash
10
10
  {{BUILD_CMD}}
@@ -17,23 +17,14 @@
17
17
  {{MCP_RULES}}
18
18
  Depth lives in skills: `token-efficiency`, `cdd`, `phase-workflow` (and
19
19
  `/caveman` for terse output) — not in this file.
20
-
21
- ## Architecture
22
- {{ARCHITECTURE_BULLETS}}
23
-
24
- ## Conventions that bite
25
- {{CONVENTIONS}}
26
-
27
20
  <!-- <<< tune-context (managed) <<< -->
28
21
 
29
22
  <!--
30
- Slot guide (delete after filling):
23
+ Slot guide (managed block only — all of these are filled from detection):
31
24
  PROJECT_NAME package/repo name
32
- ONE_LINE_WHAT_THIS_IS one sentence, what the project is
33
25
  BUILD_CMD/TEST_CMD/LINT_CMD verified commands (must actually exist)
34
26
  RUN_NOTES how to run locally, if non-obvious
35
27
  MCP_RULES one line per detected MCP server (see tune-context skill)
36
- ARCHITECTURE_BULLETS 3-6 lines, module -> responsibility
37
- CONVENTIONS only rules that bite (things that break if ignored)
38
- Keep this file thin: headings + pointers. Depth goes into skills.
28
+ Sections you write by hand are seeded once from CLAUDE.md.project.authored.tmpl
29
+ and live BELOW the block. Nothing outside the markers is ever regenerated.
39
30
  -->