@bglocation/tune-context 2.0.1 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +97 -0
- package/README.md +28 -7
- 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/hooks/context-reminder.mjs +77 -11
- 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.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.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,99 @@ 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
|
+
|
|
59
|
+
## [2.1.0] — 2026-07-26
|
|
60
|
+
|
|
61
|
+
### Changed
|
|
62
|
+
|
|
63
|
+
- **The context reminder no longer goes silent after a badly-timed crossing.**
|
|
64
|
+
It now fires a second and final time at **twice** the threshold (240k by
|
|
65
|
+
default, or 2× `TUNE_CONTEXT_REMIND_TOKENS`). The reminder tells Claude to stay
|
|
66
|
+
quiet mid-task — correctly, since only the model can see whether a refactor is
|
|
67
|
+
half-finished — but fire-once was consumed the moment the *model* read it, and
|
|
68
|
+
re-arming required a compaction. Measured on one real session: it fired at
|
|
69
|
+
~166k, Claude stayed silent mid-task, and nothing spoke again until ~346k, 2.9×
|
|
70
|
+
the threshold. There is deliberately no third notice: this is the only hook
|
|
71
|
+
that *adds* tokens to the context it exists to shrink, so the cost stays
|
|
72
|
+
bounded at one extra note per cycle.
|
|
73
|
+
- The text sent to the model now states plainly that the user cannot see the
|
|
74
|
+
reminder, so silence means they never learn about it.
|
|
75
|
+
|
|
76
|
+
### Added
|
|
77
|
+
|
|
78
|
+
- A `systemMessage` alongside the existing `additionalContext`, carrying the size
|
|
79
|
+
and `/compact` in one line. **Best-effort only** — see below.
|
|
80
|
+
|
|
81
|
+
### Notes on measurement
|
|
82
|
+
|
|
83
|
+
The intended fix was to show the user a line directly, removing the model's
|
|
84
|
+
monopoly on whether they hear about a heavy session at all. A two-marker probe
|
|
85
|
+
settled what the docs could not:
|
|
86
|
+
|
|
87
|
+
- `systemMessage` does **not** render for `UserPromptSubmit` (measured in the
|
|
88
|
+
VSCode extension), despite the settings schema promising "Display a message to
|
|
89
|
+
the user (all hooks)". The probe's `additionalContext` marker did arrive, which
|
|
90
|
+
is what makes this a clean negative rather than a probe that failed to load.
|
|
91
|
+
- `systemMessage` does **not** enter the model's context, so it costs no tokens.
|
|
92
|
+
|
|
93
|
+
It is therefore emitted but depended on by nothing, and other clients were not
|
|
94
|
+
measured. The escalation above is what actually closes the gap.
|
|
95
|
+
|
|
96
|
+
The same measurement caught a change that would have made things worse: the
|
|
97
|
+
planned model-facing text said "the user has already been shown this, do not
|
|
98
|
+
repeat it". Shipped unmeasured, it would have suppressed the only channel that
|
|
99
|
+
works.
|
|
100
|
+
|
|
8
101
|
## [2.0.1] — 2026-07-26
|
|
9
102
|
|
|
10
103
|
### Fixed
|
|
@@ -287,6 +380,10 @@ action needed on upgrade.
|
|
|
287
380
|
(`explore-cheap`, `reviewer`), the PreCompact / SessionStart / PostToolUse hook
|
|
288
381
|
suite, and the adoption report (`eval/adoption-report.mjs`).
|
|
289
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
|
|
290
387
|
[1.1.0]: https://gitlab.com/bglocation/tune-context/-/tags/v1.1.0
|
|
291
388
|
[1.0.1]: https://gitlab.com/bglocation/tune-context/-/tags/v1.0.1
|
|
292
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
|
|
@@ -170,15 +185,21 @@ supplies the missing measurement: it reads the tail of the session transcript,
|
|
|
170
185
|
sums the tokens actually re-sent, and once that passes **120,000** it injects a
|
|
171
186
|
single short note suggesting `/compact`.
|
|
172
187
|
|
|
173
|
-
- **
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
- **The model picks the moment.** The
|
|
177
|
-
|
|
178
|
-
|
|
188
|
+
- **The note goes to Claude, not to your screen.** Hooks can only reach you
|
|
189
|
+
through the model — a `systemMessage` on this event was measured and does not
|
|
190
|
+
render — so what you actually see is Claude bringing it up in its reply.
|
|
191
|
+
- **The model picks the moment.** The note tells Claude to stay quiet mid-task
|
|
192
|
+
and raise it at the first natural break: the hook can measure size, but only
|
|
193
|
+
the model can see whether you're mid-refactor.
|
|
194
|
+
- **Twice per crossing at most.** Once at the threshold, once more at **240,000**
|
|
195
|
+
if the context keeps growing — because a note that lands mid-task can be
|
|
196
|
+
correctly absorbed and then forgotten. Measured on one session: it fired at
|
|
197
|
+
166k, Claude stayed silent, and nothing spoke again until 346k. The second
|
|
198
|
+
notice halves that blind spot; there is deliberately no third.
|
|
179
199
|
- **Raise it for a large context window:** `export TUNE_CONTEXT_REMIND_TOKENS=400000`.
|
|
180
200
|
120k is ~60% of a standard 200k window; the transcript doesn't record the window
|
|
181
|
-
size, so this can't be auto-scaled.
|
|
201
|
+
size, so this can't be auto-scaled. Both levels scale with the override — the
|
|
202
|
+
second notice is always at twice the threshold.
|
|
182
203
|
- **Turn it off:** delete the `UserPromptSubmit` entry from `settings.json`.
|
|
183
204
|
|
|
184
205
|
Depth lives in the `phase-workflow` and `token-efficiency` skills (both installed
|
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) {
|
package/cli/sync-doctrine.mjs
CHANGED
|
@@ -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
|
-
* @
|
|
19
|
-
* @returns {{kind: string, name: string, dest: string}[]} synced items
|
|
24
|
+
* @returns {{kind: string, name: string, source: string}[]}
|
|
20
25
|
*/
|
|
21
|
-
export function
|
|
22
|
-
const
|
|
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
|
-
|
|
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 {
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
}
|
|
@@ -137,31 +137,94 @@ function pruneState(state, now) {
|
|
|
137
137
|
return state;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
// TASK-080 fallback, chosen by the PO after measurement killed the preferred
|
|
141
|
+
// design (see userNotice()): a SECOND and final notice at twice the threshold.
|
|
142
|
+
//
|
|
143
|
+
// The problem it solves is measured, not hypothetical. Fire-once is consumed
|
|
144
|
+
// when the model reads the reminder, and the reminder itself tells the model to
|
|
145
|
+
// stay quiet mid-task — correctly, since only it can see whether a refactor is
|
|
146
|
+
// half-finished. In session c9329adb that combination cost the user everything
|
|
147
|
+
// between ~166k and ~346k: one firing, silently absorbed, and no re-arm until a
|
|
148
|
+
// compaction that nobody had suggested.
|
|
149
|
+
//
|
|
150
|
+
// Why 2x and not "every +N tokens": periodic nagging would break the rule this
|
|
151
|
+
// hook lives under — it is the only hook that ADDS tokens to the context it
|
|
152
|
+
// exists to shrink. One extra notice per cycle is a bounded, one-off cost that
|
|
153
|
+
// halves the worst-case blind spot; an unbounded series is the feature eating
|
|
154
|
+
// itself. Deliberately NOT a third level.
|
|
155
|
+
const ESCALATION_MULTIPLIER = 2;
|
|
156
|
+
|
|
157
|
+
/** How many notices this session has earned at `total`: 0, 1 or 2. */
|
|
158
|
+
function levelFor(total, threshold) {
|
|
159
|
+
if (total >= threshold * ESCALATION_MULTIPLIER) return 2;
|
|
160
|
+
if (total >= threshold) return 1;
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
140
164
|
/**
|
|
141
165
|
* Pure decision: given the previous reading for this session and the current
|
|
142
166
|
* one, should a reminder fire, and what is the session's next state?
|
|
143
167
|
*
|
|
144
|
-
* Fires
|
|
145
|
-
* costs tokens on the very prompt it
|
|
146
|
-
* that nagged every turn would be
|
|
168
|
+
* Fires at most TWICE per crossing (threshold, then 2x threshold), never on
|
|
169
|
+
* every prompt above the line. A reminder costs tokens on the very prompt it
|
|
170
|
+
* appears in, so a context-efficiency tool that nagged every turn would be
|
|
171
|
+
* spending the thing it claims to save.
|
|
147
172
|
*/
|
|
148
173
|
export function decide(prev, total, threshold) {
|
|
149
|
-
|
|
174
|
+
// `fired: true` is how state written before TASK-080 recorded a single
|
|
175
|
+
// firing; read it as level 1 so an in-flight session whose hook is upgraded
|
|
176
|
+
// mid-run is not re-notified from scratch.
|
|
177
|
+
let reached = typeof prev?.levels === 'number' ? prev.levels : prev?.fired === true ? 1 : 0;
|
|
150
178
|
// A large drop means the context was compacted/cleared: arm again so the
|
|
151
179
|
// NEXT time it grows past the threshold the user hears about it.
|
|
152
|
-
if (prev && typeof prev.last === 'number' && total < prev.last * REARM_DROP_RATIO)
|
|
180
|
+
if (prev && typeof prev.last === 'number' && total < prev.last * REARM_DROP_RATIO) reached = 0;
|
|
181
|
+
|
|
182
|
+
const level = levelFor(total, threshold);
|
|
183
|
+
const remind = level > reached;
|
|
184
|
+
return { remind, escalated: remind && level === 2, next: { last: total, levels: Math.max(reached, level), ts: Date.now() } };
|
|
185
|
+
}
|
|
153
186
|
|
|
154
|
-
|
|
155
|
-
|
|
187
|
+
/**
|
|
188
|
+
* BEST-EFFORT user-facing channel (`systemMessage`). Emitted because it is free
|
|
189
|
+
* and correct per the hook schema — NOT because it is known to be shown.
|
|
190
|
+
*
|
|
191
|
+
* Measured 2026-07-26, TASK-080, with a two-marker probe: `systemMessage` does
|
|
192
|
+
* not reach the model's context (so it costs no tokens), and it did not appear
|
|
193
|
+
* on screen either, in the VSCode extension, for a UserPromptSubmit hook. The
|
|
194
|
+
* same probe's `additionalContext` marker arrived, which is what makes that a
|
|
195
|
+
* clean negative rather than a probe that failed to load. Other clients were
|
|
196
|
+
* not measured, so this stays: a line that costs nothing and may render
|
|
197
|
+
* somewhere else. **Nothing may depend on it** — in particular reminderText()
|
|
198
|
+
* must never tell the model the user has already seen the number.
|
|
199
|
+
*
|
|
200
|
+
* One sentence, deliberately: the size is the one fact a user cannot get
|
|
201
|
+
* anywhere else, and /compact is the one action.
|
|
202
|
+
*/
|
|
203
|
+
export function userNotice(total) {
|
|
204
|
+
const k = Math.round(total / 1000);
|
|
205
|
+
return `[tune-context] Context is at ~${k}k tokens — consider /compact when the current thread closes.`;
|
|
156
206
|
}
|
|
157
207
|
|
|
158
|
-
|
|
208
|
+
/**
|
|
209
|
+
* The channel that actually works (`additionalContext`): the model reads it and
|
|
210
|
+
* relays it. The timing judgement stays here, because only the model can tell
|
|
211
|
+
* whether the current thread is finished — but see the second sentence: it must
|
|
212
|
+
* relay eventually, because staying silent forever means the user never learns.
|
|
213
|
+
*/
|
|
214
|
+
export function reminderText(total, { escalated = false } = {}) {
|
|
159
215
|
const k = Math.round(total / 1000);
|
|
160
216
|
return [
|
|
161
217
|
`[tune-context] This session is now carrying ~${k}k tokens of context, and every further turn re-sends all of it.`,
|
|
218
|
+
// The measured failure this sentence exists to prevent: at ~166k the model
|
|
219
|
+
// stayed quiet mid-task and simply never came back to it, so the user first
|
|
220
|
+
// heard about the cost at ~346k.
|
|
221
|
+
'The user CANNOT see this reminder — it reaches you alone, so if you never mention it they will never know.',
|
|
162
222
|
'If the current work thread is finished: suggest /compact (or /clear when switching to unrelated work).',
|
|
163
|
-
'If you are mid-task:
|
|
223
|
+
'If you are mid-task: stay quiet now, but raise it at the first natural break rather than dropping it.',
|
|
164
224
|
'The PreCompact hook preserves branch, dirty files and recent commits across a compaction — judgment calls and open threads are NOT preserved, so write those down first.',
|
|
225
|
+
// Kept short on purpose: it rides ON TOP of the full reminder, inside the
|
|
226
|
+
// same <700 char budget.
|
|
227
|
+
...(escalated ? ['This is the second and final automatic notice — nothing more fires until a compaction.'] : []),
|
|
165
228
|
].join(' ');
|
|
166
229
|
}
|
|
167
230
|
|
|
@@ -182,7 +245,7 @@ function main() {
|
|
|
182
245
|
const session = input.session_id || 'unknown';
|
|
183
246
|
const path = stateFile();
|
|
184
247
|
const state = readState(path);
|
|
185
|
-
const { remind, next } = decide(state[session], total, thresholdTokens());
|
|
248
|
+
const { remind, escalated, next } = decide(state[session], total, thresholdTokens());
|
|
186
249
|
|
|
187
250
|
state[session] = next;
|
|
188
251
|
try {
|
|
@@ -194,11 +257,14 @@ function main() {
|
|
|
194
257
|
}
|
|
195
258
|
|
|
196
259
|
if (!remind) return;
|
|
260
|
+
// additionalContext is the channel that is known to arrive; systemMessage
|
|
261
|
+
// is emitted alongside it as a free best-effort (see userNotice()).
|
|
197
262
|
process.stdout.write(
|
|
198
263
|
JSON.stringify({
|
|
264
|
+
systemMessage: userNotice(total),
|
|
199
265
|
hookSpecificOutput: {
|
|
200
266
|
hookEventName: 'UserPromptSubmit',
|
|
201
|
-
additionalContext: reminderText(total),
|
|
267
|
+
additionalContext: reminderText(total, { escalated }),
|
|
202
268
|
},
|
|
203
269
|
}),
|
|
204
270
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bglocation/tune-context",
|
|
3
|
-
"version": "2.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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
3
|
-
|
|
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 (
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-->
|