@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
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
|
}
|
package/eval/adoption-report.mjs
CHANGED
|
@@ -10,10 +10,16 @@
|
|
|
10
10
|
// (`claude_code.cost.usage`, needs a collector) — never a hook. This report
|
|
11
11
|
// measures *behavior* (a proxy), not dollars. OTEL is the opt-in complement.
|
|
12
12
|
// • caveman can't be measured: it's an output style, not a tool call.
|
|
13
|
+
// • RAG availability (the `~` line in the verdict) rests on a project's
|
|
14
|
+
// `.mcp.json` declaring a semantic-search server that predates the sessions
|
|
15
|
+
// — config, not introspection. It does NOT prove the server exposed
|
|
16
|
+
// `search_codebase`, and it says nothing about user-scope servers, whose
|
|
17
|
+
// config file carries no usable timestamp. See eval/mcp-probe.mjs (TASK-083).
|
|
13
18
|
import { existsSync, readFileSync } from 'node:fs';
|
|
14
19
|
import { join } from 'node:path';
|
|
15
20
|
import { parseArgs } from 'node:util';
|
|
16
21
|
import { claudeBaseDir } from '../hooks/config-dir.mjs';
|
|
22
|
+
import { classifyAvailability, probeProjectMcp } from './mcp-probe.mjs';
|
|
17
23
|
|
|
18
24
|
const DOCTRINE_SKILLS = new Set([
|
|
19
25
|
'token-efficiency',
|
|
@@ -127,13 +133,29 @@ function countDuplicateRegistrationPairs(records) {
|
|
|
127
133
|
return { pairs, assessable };
|
|
128
134
|
}
|
|
129
135
|
|
|
130
|
-
|
|
136
|
+
/**
|
|
137
|
+
* @param {object[]} records
|
|
138
|
+
* @param {{probe?: (cwd: string) => {names: string[], mtimeMs: number|null}}} [options]
|
|
139
|
+
* probe - MCP config probe, injectable for tests (see eval/mcp-probe.mjs).
|
|
140
|
+
*/
|
|
141
|
+
export function aggregate(records, { probe = probeProjectMcp } = {}) {
|
|
131
142
|
const search = { rag: 0, grepTool: 0, shell: 0, glob: 0 };
|
|
132
143
|
const skills = new Map();
|
|
133
144
|
const subagents = new Map();
|
|
134
145
|
const readCounts = new Map(); // `${session}\u0000${file}` -> times read in that session
|
|
135
146
|
const sessionSet = new Set();
|
|
136
147
|
const cwdSet = new Set();
|
|
148
|
+
// TASK-083: search counted per project as well as globally. The global
|
|
149
|
+
// counter alone reads one RAG-using project as coverage for all the others —
|
|
150
|
+
// TASK-070 already specified this denominator as per-cwd, and it never landed.
|
|
151
|
+
const searchByCwd = new Map(); // cwd -> {rag, grepTool, shell, glob}
|
|
152
|
+
// Inputs to the availability probe's temporal guard. Session start is the
|
|
153
|
+
// earliest ts of ANY record in that session, not the earliest one in this
|
|
154
|
+
// cwd: a session that began elsewhere and moved here started before its first
|
|
155
|
+
// record here, and dating the config against the later value would loosen the
|
|
156
|
+
// guard in exactly the direction that fabricates verdicts.
|
|
157
|
+
const sessionStart = new Map(); // session key -> earliest ts (ms), or null if any record lacks one
|
|
158
|
+
const cwdSessions = new Map(); // cwd -> Set(session key)
|
|
137
159
|
let reads = 0;
|
|
138
160
|
let edits = 0;
|
|
139
161
|
let bash = 0;
|
|
@@ -143,7 +165,30 @@ export function aggregate(records) {
|
|
|
143
165
|
|
|
144
166
|
for (const r of records) {
|
|
145
167
|
if (r.session != null) sessionSet.add(r.session);
|
|
146
|
-
|
|
168
|
+
// A record with no cwd gets NO bucket of its own and can never promote a
|
|
169
|
+
// project to 'configured': there is no project whose config we could check.
|
|
170
|
+
// It still counts toward every global figure. Non-string cwds are treated
|
|
171
|
+
// the same way rather than trusted: the probe joins this value into a path,
|
|
172
|
+
// and parseLog vets only `lever`, so a malformed line could otherwise take
|
|
173
|
+
// the whole report down.
|
|
174
|
+
if (typeof r.cwd === 'string' && r.cwd !== '') {
|
|
175
|
+
cwdSet.add(r.cwd);
|
|
176
|
+
if (!searchByCwd.has(r.cwd)) searchByCwd.set(r.cwd, { rag: 0, grepTool: 0, shell: 0, glob: 0 });
|
|
177
|
+
if (!cwdSessions.has(r.cwd)) cwdSessions.set(r.cwd, new Set());
|
|
178
|
+
cwdSessions.get(r.cwd).add(r.session ?? '');
|
|
179
|
+
}
|
|
180
|
+
// One undateable record poisons its session's start: the missing ts could
|
|
181
|
+
// have been the earliest, so the guard would have nothing sound to compare
|
|
182
|
+
// against and every project that session touched falls back to 'unknown'.
|
|
183
|
+
const startKey = r.session ?? '';
|
|
184
|
+
const startedAt = Date.parse(r.ts);
|
|
185
|
+
if (!sessionStart.has(startKey)) {
|
|
186
|
+
sessionStart.set(startKey, Number.isNaN(startedAt) ? null : startedAt);
|
|
187
|
+
} else {
|
|
188
|
+
const known = sessionStart.get(startKey);
|
|
189
|
+
if (known !== null) sessionStart.set(startKey, Number.isNaN(startedAt) ? null : Math.min(known, startedAt));
|
|
190
|
+
}
|
|
191
|
+
const perCwd = searchByCwd.get(r.cwd) ?? null;
|
|
147
192
|
switch (r.lever) {
|
|
148
193
|
case 'search': {
|
|
149
194
|
// Split content search by TOOL — the only reliable "is this a codebase
|
|
@@ -154,10 +199,19 @@ export function aggregate(records) {
|
|
|
154
199
|
// so the split reinterprets existing logs — no format change, hook
|
|
155
200
|
// untouched. See TASK-069.
|
|
156
201
|
const tool = typeof r.tool === 'string' ? r.tool : '';
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
202
|
+
// One classification, counted twice: globally and into this project's
|
|
203
|
+
// bucket. Splitting the rule in two would let the per-project view and
|
|
204
|
+
// the global view disagree about what a RAG call is.
|
|
205
|
+
const kind =
|
|
206
|
+
r.detail === 'rag' || tool.includes('search_codebase')
|
|
207
|
+
? 'rag'
|
|
208
|
+
: r.detail === 'glob' || tool === 'Glob'
|
|
209
|
+
? 'glob'
|
|
210
|
+
: tool === 'Grep'
|
|
211
|
+
? 'grepTool'
|
|
212
|
+
: 'shell'; // Bash-grep / ambiguous — reported, excluded from RAG-share
|
|
213
|
+
search[kind]++;
|
|
214
|
+
if (perCwd) perCwd[kind]++;
|
|
161
215
|
break;
|
|
162
216
|
}
|
|
163
217
|
case 'skill':
|
|
@@ -210,12 +264,54 @@ export function aggregate(records) {
|
|
|
210
264
|
const { pairs: dedupPairs, assessable: dedupAssessable } = countDuplicateRegistrationPairs(records);
|
|
211
265
|
const dedupShare = dedupAssessable > 0 ? dedupPairs / dedupAssessable : 0;
|
|
212
266
|
|
|
267
|
+
// Was RAG even available in each project? Decided per cwd, so one RAG-using
|
|
268
|
+
// project can no longer cover for the rest (TASK-083). A project is dated by
|
|
269
|
+
// the EARLIEST session that touched it: the claim "configured before these
|
|
270
|
+
// sessions" has to hold for all of them, and one unknown start makes the
|
|
271
|
+
// whole project unknown.
|
|
272
|
+
const availability = { observed: 0, configured: 0, unknown: 0, byCwd: [], names: [] };
|
|
273
|
+
const configuredNames = new Set();
|
|
274
|
+
for (const cwd of [...cwdSet].sort()) {
|
|
275
|
+
const sessionsHere = cwdSessions.get(cwd);
|
|
276
|
+
let sessionStartMs = null;
|
|
277
|
+
for (const key of sessionsHere) {
|
|
278
|
+
const start = sessionStart.get(key) ?? null;
|
|
279
|
+
if (start === null) {
|
|
280
|
+
sessionStartMs = null;
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
sessionStartMs = sessionStartMs === null ? start : Math.min(sessionStartMs, start);
|
|
284
|
+
}
|
|
285
|
+
const { tier, names } = classifyAvailability(cwd, {
|
|
286
|
+
ragObserved: searchByCwd.get(cwd).rag > 0,
|
|
287
|
+
sessionStartMs,
|
|
288
|
+
// The SAME PO-frozen floor as the global verdict, applied where the claim
|
|
289
|
+
// is actually made. Judgments moved per-project in TASK-083, and the
|
|
290
|
+
// global count stopped guaranteeing a per-project sample: a log with 10
|
|
291
|
+
// sessions can hold a project with one, and "available, never reached
|
|
292
|
+
// for" over one session is exactly the trend-from-a-sample the floor
|
|
293
|
+
// exists to refuse. No new threshold is chosen here — the number stays
|
|
294
|
+
// frozen above, this only stops the probe routing around it.
|
|
295
|
+
sampleSuffices: sessionsHere.size >= MIN_SESSIONS_FOR_VERDICT,
|
|
296
|
+
probe,
|
|
297
|
+
});
|
|
298
|
+
availability[tier]++;
|
|
299
|
+
availability.byCwd.push([cwd, tier]);
|
|
300
|
+
for (const name of names) configuredNames.add(name);
|
|
301
|
+
}
|
|
302
|
+
availability.names = [...configuredNames].sort();
|
|
303
|
+
|
|
213
304
|
return {
|
|
214
305
|
total: records.length,
|
|
215
306
|
sessions: sessionSet.size,
|
|
216
307
|
cwds: cwdSet.size,
|
|
217
308
|
search,
|
|
218
309
|
ragShare,
|
|
310
|
+
// Per-project search counts and the availability tier each one earned.
|
|
311
|
+
// Presentation stays a single aggregate verdict line (PO decision 3,
|
|
312
|
+
// 2026-07-27); these are what a per-project section would be built from.
|
|
313
|
+
searchByCwd: [...searchByCwd.entries()].sort((a, b) => a[0].localeCompare(b[0])),
|
|
314
|
+
availability,
|
|
219
315
|
skills: [...skills.entries()].sort((a, b) => b[1] - a[1]),
|
|
220
316
|
subagents: [...subagents.entries()].sort((a, b) => b[1] - a[1]),
|
|
221
317
|
reads,
|
|
@@ -319,16 +415,26 @@ export function formatReport(a) {
|
|
|
319
415
|
' One session is a sample, not a trend; collect more, then re-run.',
|
|
320
416
|
);
|
|
321
417
|
} else {
|
|
322
|
-
|
|
418
|
+
const { configured, unknown } = a.availability;
|
|
419
|
+
// The `~` line speaks only for the projects the probe could judge, so it
|
|
420
|
+
// may replace this one only when that is ALL of them. Otherwise the
|
|
421
|
+
// projects it cannot judge would go unmentioned entirely — a worse
|
|
422
|
+
// silence than the blind spot TASK-070 admitted to out loud.
|
|
423
|
+
const probeSpeaksForEveryProject = configured > 0 && unknown === 0;
|
|
424
|
+
if (a.search.rag === 0 && !probeSpeaksForEveryProject) {
|
|
323
425
|
// Availability (Option 1, PO 2026-07-24): search_codebase never observed
|
|
324
426
|
// → report as unavailable, never ✗ and never ✓. The behavioral log can't
|
|
325
427
|
// tell "RAG not installed here" from "installed but never reached for",
|
|
326
428
|
// so it must not accuse — a known, accepted blind spot (TASK-070 notes).
|
|
429
|
+
// TASK-083 leaves this wording untouched for the case it was written
|
|
430
|
+
// for: no observation AND no config the probe could date. When the probe
|
|
431
|
+
// does have something, the `~` line below replaces this one — it answers
|
|
432
|
+
// the very question this sentence admits it cannot.
|
|
327
433
|
L.push(
|
|
328
434
|
' · RAG not observed — no search_codebase in these sessions. Not counted' +
|
|
329
435
|
' as ✗: can\'t tell "not installed" from "installed but unused".',
|
|
330
436
|
);
|
|
331
|
-
} else {
|
|
437
|
+
} else if (a.search.rag > 0) {
|
|
332
438
|
// rag > 0 ⇒ denominator (rag + grepTool) > 0 ⇒ ragShare is a number.
|
|
333
439
|
L.push(
|
|
334
440
|
a.ragShare >= 0.5
|
|
@@ -336,6 +442,20 @@ export function formatReport(a) {
|
|
|
336
442
|
: ' ✗ RAG lever weak — grep still dominates code search. Revisit index/skill wiring.',
|
|
337
443
|
);
|
|
338
444
|
}
|
|
445
|
+
// Earned middle state (TASK-083, PO decision 1): the project declares a
|
|
446
|
+
// non-rejected semantic-search server in a `.mcp.json` older than every
|
|
447
|
+
// session logged there, and search_codebase still never appears. Soft `~`,
|
|
448
|
+
// never a hard ✗ — config names servers, not tools, so this is evidence
|
|
449
|
+
// the lever was probably within reach, not proof it was.
|
|
450
|
+
if (configured > 0) {
|
|
451
|
+
const names = a.availability.names;
|
|
452
|
+
L.push(
|
|
453
|
+
` ~ RAG likely available, not reached for — ${configured} of ${a.cwds} project(s) declare` +
|
|
454
|
+
` a semantic-search MCP server in .mcp.json (${names.join(', ')}) predating every session` +
|
|
455
|
+
' logged there, yet no search_codebase call. Config, not introspection:' +
|
|
456
|
+
` confirm with \`claude mcp get ${names[0]}\`.`,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
339
459
|
// Zero `Skill` calls is NOT proof doctrine is unused — narrow reads,
|
|
340
460
|
// contracts-first edits, and delegation are the method working *without*
|
|
341
461
|
// a tool call. A hard ✗ here would penalize exactly the discipline this
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// MCP config probe — the "was RAG even available?" half of the adoption report
|
|
2
|
+
// (TASK-083, closing Option 3 deferred in TASK-070).
|
|
3
|
+
//
|
|
4
|
+
// The report can see that `search_codebase` was never called. It cannot see
|
|
5
|
+
// WHY, and the difference matters: "no RAG installed here" is not a finding,
|
|
6
|
+
// "RAG installed and ignored" is exactly the finding the report exists for.
|
|
7
|
+
// TASK-070 refused to guess and printed neither ✓ nor ✗. This module earns a
|
|
8
|
+
// middle state instead of inventing a hard one.
|
|
9
|
+
//
|
|
10
|
+
// Three gaps sit between "configured" and "was available in that session", and
|
|
11
|
+
// each one alone is enough to manufacture a false accusation:
|
|
12
|
+
//
|
|
13
|
+
// 1. server ≠ tool — config lists servers, not their tools. A server
|
|
14
|
+
// named `rag` may expose `query`, not `search_codebase`
|
|
15
|
+
// (see classifyMcpServer's own warning in cli/detect.mjs).
|
|
16
|
+
// 2. now ≠ then — the probe reads today's config against a historical
|
|
17
|
+
// log. Installing RAG today must not retroactively
|
|
18
|
+
// convict every session that predates the install.
|
|
19
|
+
// 3. configured ≠ approved — a project `.mcp.json` server the user never
|
|
20
|
+
// approved is "⏸ Pending approval": present, not running.
|
|
21
|
+
//
|
|
22
|
+
// Gap 2 is closed by the mtime guard below. Gap 3 is only NARROWED: explicit
|
|
23
|
+
// rejections (`disabledMcpjsonServers`) are honoured, but a server still
|
|
24
|
+
// awaiting approval stays "configured" on purpose — absence from
|
|
25
|
+
// `enabledMcpjsonServers` does not mean disabled (the user is asked, and may
|
|
26
|
+
// well say yes), so reading it as unavailable would drop the very case this
|
|
27
|
+
// module exists to surface. Gap 1 is not closed at all. Which is precisely why
|
|
28
|
+
// the strongest verdict here is a soft `~`, never a hard ✗ (PO decision 1,
|
|
29
|
+
// 2026-07-27).
|
|
30
|
+
//
|
|
31
|
+
// SCOPE — project `.mcp.json` only, measured rather than assumed (TASK-083
|
|
32
|
+
// review, 2026-07-27): `~/.claude.json`, which declares the user and local
|
|
33
|
+
// scopes, is rewritten by Claude Code continuously — it holds project history
|
|
34
|
+
// and session state, not just `mcpServers`, so its mtime is always ~now. The
|
|
35
|
+
// temporal guard would therefore degrade EVERY user-scope server to unknown,
|
|
36
|
+
// silently disabling the probe exactly where people install RAG most often
|
|
37
|
+
// (`claude mcp add --scope user`). A project `.mcp.json` is a static,
|
|
38
|
+
// git-tracked file whose mtime means something. The honest answer for the user
|
|
39
|
+
// scope is "unknown", and it stays "unknown" — recovering it needs the session
|
|
40
|
+
// to record its own server list going forward, not a cleverer inference.
|
|
41
|
+
import { statSync } from 'node:fs';
|
|
42
|
+
import { join } from 'node:path';
|
|
43
|
+
import { detectExistingConfig, detectMcpServers, detectRejectedMcpServers } from '../cli/detect.mjs';
|
|
44
|
+
|
|
45
|
+
// The one classifyMcpServer bucket that could plausibly serve search_codebase.
|
|
46
|
+
// Single source of truth, already maintained for the CLAUDE.md MCP rules — a
|
|
47
|
+
// second allowlist here would drift from it (PO decision 2, 2026-07-27).
|
|
48
|
+
const SEMANTIC_SEARCH = 'semantic code search';
|
|
49
|
+
|
|
50
|
+
/** mtime of a path in epoch ms, or null when it is missing/unreadable. */
|
|
51
|
+
function mtimeMs(path) {
|
|
52
|
+
try {
|
|
53
|
+
return statSync(path).mtimeMs;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Semantic-search servers a project's OWN `.mcp.json` declares and no settings
|
|
61
|
+
* file rejects, plus that file's mtime — the raw facts, no judgment.
|
|
62
|
+
*
|
|
63
|
+
* Rejections are read without any temporal guard of their own, deliberately: a
|
|
64
|
+
* server rejected today that was live back then reads as "unknown" instead of
|
|
65
|
+
* "configured", which is an error toward silence. That direction is always
|
|
66
|
+
* allowed here; the opposite one is what this whole module exists to prevent.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} cwd - project directory as recorded in the adoption log
|
|
69
|
+
* @returns {{names: string[], mtimeMs: number|null}}
|
|
70
|
+
*/
|
|
71
|
+
export function probeProjectMcp(cwd) {
|
|
72
|
+
const config = detectExistingConfig(cwd);
|
|
73
|
+
const rejected = detectRejectedMcpServers([
|
|
74
|
+
config.projectSettingsJson,
|
|
75
|
+
config.projectSettingsLocalJson,
|
|
76
|
+
config.userSettingsJson,
|
|
77
|
+
]);
|
|
78
|
+
const names = detectMcpServers(cwd)
|
|
79
|
+
.filter((s) => s.scope === 'project' && s.classification === SEMANTIC_SEARCH && !rejected.has(s.name))
|
|
80
|
+
.map((s) => s.name);
|
|
81
|
+
return { names, mtimeMs: mtimeMs(join(cwd, '.mcp.json')) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* How much this project's log entitles us to claim about RAG availability.
|
|
86
|
+
*
|
|
87
|
+
* 'observed' — a search_codebase-class call is in the log for this cwd.
|
|
88
|
+
* Proven. The config is not even consulted.
|
|
89
|
+
* 'configured' — not observed, but the project declares a non-rejected
|
|
90
|
+
* semantic-search server in a `.mcp.json` that predates every
|
|
91
|
+
* session logged here. Likely available, unused. Soft `~`.
|
|
92
|
+
* 'unknown' — everything else. TASK-070's wording, unchanged.
|
|
93
|
+
*
|
|
94
|
+
* The temporal guard is a valid inference, not a heuristic:
|
|
95
|
+
* `mtime < session start` ⇒ the file was not touched from before that session
|
|
96
|
+
* until now ⇒ the content read today IS the content that session saw. The
|
|
97
|
+
* converse buys nothing — a file changed during or after the session says
|
|
98
|
+
* nothing about its state back then — so it drops to 'unknown'. A fresh
|
|
99
|
+
* `git clone` stamps mtime at checkout time and so reads "too new": fail-safe,
|
|
100
|
+
* which is the house direction. The one case that fails the other way is a copy
|
|
101
|
+
* that preserves mtime (`cp -p`, `rsync -a`, a restored backup) — the file can
|
|
102
|
+
* then look older than a session it was never present for. Left undefended
|
|
103
|
+
* deliberately: it is exotic, and what it could inflate is a soft `~`, not an
|
|
104
|
+
* accusation.
|
|
105
|
+
*
|
|
106
|
+
* 'configured' additionally answers to a sample floor, passed in as
|
|
107
|
+
* `sampleSuffices` because the number is the report's to freeze with the PO, not
|
|
108
|
+
* this module's to pick. It applies to this tier and not to 'observed' for a
|
|
109
|
+
* reason: a call either happened or it didn't, but "available and never reached
|
|
110
|
+
* for" is a claim about ABSENCE across sessions, and absence is exactly what one
|
|
111
|
+
* session cannot establish.
|
|
112
|
+
*
|
|
113
|
+
* `probe` is injected so callers can prove the ordering above: on an
|
|
114
|
+
* 'observed' project it is never called, so a probe that throws is harmless.
|
|
115
|
+
*
|
|
116
|
+
* `names` is populated only for 'configured' — the one tier whose claim rests
|
|
117
|
+
* on those servers, and so the one whose report line has to name them for the
|
|
118
|
+
* reader to check. The other tiers carry nothing to confirm.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} cwd
|
|
121
|
+
* @param {{ragObserved: boolean, sessionStartMs: number|null, sampleSuffices: boolean, probe?: (cwd: string) => {names: string[], mtimeMs: number|null}}} facts
|
|
122
|
+
* sessionStartMs - earliest logged timestamp of any session that touched
|
|
123
|
+
* this cwd; null when unknown, which alone forces 'unknown'.
|
|
124
|
+
* sampleSuffices - has THIS project logged enough sessions to be spoken
|
|
125
|
+
* about? False forces 'unknown' without consulting the config.
|
|
126
|
+
* @returns {{tier: 'observed'|'configured'|'unknown', names: string[]}}
|
|
127
|
+
*/
|
|
128
|
+
export function classifyAvailability(cwd, { ragObserved, sessionStartMs, sampleSuffices, probe = probeProjectMcp }) {
|
|
129
|
+
if (ragObserved) return { tier: 'observed', names: [] }; // observation beats inference — no probe
|
|
130
|
+
if (!sampleSuffices) return { tier: 'unknown', names: [] }; // too few sessions here to speak of absence
|
|
131
|
+
if (sessionStartMs === null) return { tier: 'unknown', names: [] }; // nothing to date the config against
|
|
132
|
+
const { names, mtimeMs: configMtime } = probe(cwd);
|
|
133
|
+
if (names.length === 0) return { tier: 'unknown', names: [] };
|
|
134
|
+
if (configMtime === null || configMtime >= sessionStartMs) return { tier: 'unknown', names: [] };
|
|
135
|
+
return { tier: 'configured', names };
|
|
136
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bglocation/tune-context",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.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
|
-->
|