@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +104 -0
- package/README.md +75 -13
- package/bin/tune-context.mjs +108 -11
- package/cli/generate.mjs +146 -12
- package/cli/sync-doctrine.mjs +28 -11
- package/cli/verify.mjs +116 -4
- package/eval/adoption-report.mjs +128 -8
- package/eval/mcp-probe.mjs +136 -0
- package/package.json +1 -1
- package/skills/tune-context/SKILL.md +18 -4
- package/skills/tune-context/templates/CLAUDE.md.project.authored.tmpl +16 -0
- package/skills/tune-context/templates/CLAUDE.md.project.tmpl +7 -16
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
50
|
-
|
|
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).**
|
|
68
|
-
|
|
69
|
-
then generates a lean project `CLAUDE.md`, wires the doctrine
|
|
70
|
-
hooks + permissions — idempotently, never clobbering
|
|
71
|
-
the full-fidelity path (it also handles wiki wiring
|
|
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
|
|
88
|
-
deterministic tool cannot supply — run `/tune-context` if you want them:
|
|
113
|
+
Two flags are worth knowing:
|
|
89
114
|
|
|
90
|
-
|
|
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 (
|
|
201
|
-
|
|
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
|
package/bin/tune-context.mjs
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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) {
|