@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.
@@ -14,25 +14,42 @@ import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, rmSync } from 'n
14
14
  import { dirname, join } from 'node:path';
15
15
 
16
16
  /**
17
+ * Everything this package deploys into a Claude config dir, as
18
+ * `{kind, name, source}` — one enumeration, used both by the copier below and
19
+ * by verify's staleness check (TASK-081). Two lists would drift apart, and a
20
+ * freshness check that walks a *different* set than the sync did would go
21
+ * green on exactly the file nobody thought to add to it.
22
+ *
17
23
  * @param {string} pkgRoot - root of the tune-context package (contains skills/, agents/, hooks/)
18
- * @param {string} claudeDir - destination Claude config dir (e.g. ~/.claude)
19
- * @returns {{kind: string, name: string, dest: string}[]} synced items
24
+ * @returns {{kind: string, name: string, source: string}[]}
20
25
  */
21
- export function syncDoctrine(pkgRoot, claudeDir) {
22
- const synced = [];
26
+ export function doctrineEntries(pkgRoot) {
27
+ const entries = [];
23
28
  for (const kind of ['skills', 'agents', 'hooks']) {
24
29
  const from = join(pkgRoot, kind);
25
30
  if (!existsSync(from)) continue;
26
31
  for (const entry of readdirSync(from)) {
27
32
  if (kind === 'hooks' && entry === 'hooks.json') continue; // plugin-only manifest, not a script to install
28
- const s = join(from, entry);
29
- const d = join(claudeDir, kind, entry);
30
- rmSync(d, { recursive: true, force: true });
31
- mkdirSync(dirname(d), { recursive: true });
32
- cpSync(s, d, { recursive: true });
33
- if (kind === 'hooks') chmodSync(d, 0o755);
34
- synced.push({ kind, name: entry, dest: d });
33
+ entries.push({ kind, name: entry, source: join(from, entry) });
35
34
  }
36
35
  }
36
+ return entries;
37
+ }
38
+
39
+ /**
40
+ * @param {string} pkgRoot - root of the tune-context package (contains skills/, agents/, hooks/)
41
+ * @param {string} claudeDir - destination Claude config dir (e.g. ~/.claude)
42
+ * @returns {{kind: string, name: string, dest: string}[]} synced items
43
+ */
44
+ export function syncDoctrine(pkgRoot, claudeDir) {
45
+ const synced = [];
46
+ for (const { kind, name, source } of doctrineEntries(pkgRoot)) {
47
+ const d = join(claudeDir, kind, name);
48
+ rmSync(d, { recursive: true, force: true });
49
+ mkdirSync(dirname(d), { recursive: true });
50
+ cpSync(source, d, { recursive: true });
51
+ if (kind === 'hooks') chmodSync(d, 0o755);
52
+ synced.push({ kind, name, dest: d });
53
+ }
37
54
  return synced;
38
55
  }
package/cli/verify.mjs CHANGED
@@ -1,9 +1,43 @@
1
1
  // Re-checks what generate.mjs claims to have done — "the merge actually
2
2
  // landed" per SKILL.md §3. Never trust that a write succeeded just because
3
3
  // the function returned; re-read the target file and parse it.
4
- import { existsSync, readFileSync, statSync } from 'node:fs';
4
+ import { createHash } from 'node:crypto';
5
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
5
6
  import { homedir } from 'node:os';
6
7
  import { basename, join, resolve } from 'node:path';
8
+ import { doctrineEntries } from './sync-doctrine.mjs';
9
+
10
+ /**
11
+ * Content fingerprint of a deployed artifact — a file, or a whole directory
12
+ * (skills are directories; `skills/tune-context` carries its own templates/).
13
+ *
14
+ * A directory hashes as the sorted list of `relative path + content hash`, so a
15
+ * file MISSING from inside a synced skill, or an extra one left behind by an
16
+ * older shape, is drift too — not just an edited byte. Sorting is what makes it
17
+ * reproducible across filesystems that hand back readdir in different orders.
18
+ *
19
+ * Mode is deliberately not hashed: syncDoctrine chmods hooks to 755 after
20
+ * copying, so folding the exec bit in here would report a rewrite as drift on
21
+ * every platform where the package's own bits differ. The exec bit already has
22
+ * its own check.
23
+ *
24
+ * @returns {string|null} null when the path does not exist
25
+ */
26
+ export function fingerprint(path) {
27
+ if (!existsSync(path)) return null;
28
+ const hash = createHash('sha256');
29
+ const walk = (current, rel) => {
30
+ const stat = statSync(current);
31
+ if (stat.isDirectory()) {
32
+ for (const entry of readdirSync(current).sort()) walk(join(current, entry), rel ? `${rel}/${entry}` : entry);
33
+ return;
34
+ }
35
+ hash.update(`${rel}\0`);
36
+ hash.update(createHash('sha256').update(readFileSync(current)).digest());
37
+ };
38
+ walk(path, '');
39
+ return hash.digest('hex');
40
+ }
7
41
 
8
42
  function readJson(path) {
9
43
  try {
@@ -50,9 +84,83 @@ function expandHome(p) {
50
84
  const PLUGIN_PATH_HINT = /\$\{CLAUDE_PLUGIN_ROOT\}|[\\/]plugins[\\/]/;
51
85
 
52
86
  /**
53
- * @returns {{ok: boolean, checks: {name: string, ok: boolean, detail?: string}[]}}
87
+ * Are the deployed copies still the ones this package ships? (TASK-081)
88
+ *
89
+ * `npm i -g` updates the package; it does NOT touch what an earlier `init`
90
+ * copied into ~/.claude. settings.json points at those copies, so a release
91
+ * that changes hooks/ goes live only after another `init`. Observed right after
92
+ * publishing 2.1.0: the CLI reported 2.1.0, verify was all green, and the
93
+ * deployed context-reminder.mjs was still the 2.0.x file — the *only* file the
94
+ * release had changed.
95
+ *
96
+ * Two rules keep this from crying wolf:
97
+ *
98
+ * - **Only what is actually deployed is compared.** An artifact absent from
99
+ * claudeDir is not reported here at all, which is what makes a plugin-channel
100
+ * install (registrations pointing at `${CLAUDE_PLUGIN_ROOT}`, no copies
101
+ * anywhere) impossible to false-red: there is nothing to compare, so nothing
102
+ * is claimed. Presence of the things that MUST exist is a separate check.
103
+ * - **No package to compare against means "not checked", never a tick.** A
104
+ * caller that cannot say where the installed package lives gets `unknown`,
105
+ * which prints as `?` and neither passes nor fails — the TASK-070 rule that
106
+ * an unobservable thing must not be reported as verified.
107
+ *
108
+ * @param {string|null} pkgRoot - root of the installed package, or null/undefined
109
+ * @returns {{name: string, ok: boolean, detail?: string, unknown?: boolean}[]}
54
110
  */
55
- export function verify({ claudeDir, userSettingsPath, expectedHookCommands, projectSettingsPath, expectedPermissions }) {
111
+ export function staleDoctrineEntries(claudeDir, pkgRoot) {
112
+ if (!pkgRoot || !existsSync(pkgRoot)) return null;
113
+ const stale = [];
114
+ let compared = 0;
115
+ for (const { kind, name, source } of doctrineEntries(pkgRoot)) {
116
+ const deployed = join(claudeDir, kind, name);
117
+ const deployedPrint = fingerprint(deployed);
118
+ if (deployedPrint === null) continue; // not deployed through this channel — not ours to judge
119
+ compared += 1;
120
+ if (deployedPrint !== fingerprint(source)) stale.push(`${kind}/${name}`);
121
+ }
122
+ return { compared, stale };
123
+ }
124
+
125
+ function freshnessChecks(claudeDir, pkgRoot) {
126
+ const result = staleDoctrineEntries(claudeDir, pkgRoot);
127
+ if (result === null) {
128
+ return [
129
+ {
130
+ name: 'deployed skills/agents/hooks match the installed package',
131
+ ok: false,
132
+ unknown: true,
133
+ detail: 'not checked — no installed package to compare against; re-run `tune-context init` if a release changed hooks/',
134
+ },
135
+ ];
136
+ }
137
+
138
+ const { compared, stale } = result;
139
+ if (compared === 0) {
140
+ return [
141
+ {
142
+ name: 'deployed skills/agents/hooks match the installed package',
143
+ ok: false,
144
+ unknown: true,
145
+ detail: `not checked — nothing from this package is deployed under ${claudeDir}`,
146
+ },
147
+ ];
148
+ }
149
+
150
+ return [
151
+ {
152
+ name: `deployed skills/agents/hooks match the installed package (${compared} compared)`,
153
+ ok: stale.length === 0,
154
+ detail: stale.length ? `stale: ${stale.join(', ')} — content differs from the installed package; re-run \`tune-context init\`` : undefined,
155
+ },
156
+ ];
157
+ }
158
+
159
+ /**
160
+ * @param {string} [pkgRoot] - installed package root, for the staleness check
161
+ * @returns {{ok: boolean, checks: {name: string, ok: boolean, detail?: string, unknown?: boolean}[]}}
162
+ */
163
+ export function verify({ claudeDir, userSettingsPath, expectedHookCommands, projectSettingsPath, expectedPermissions, pkgRoot }) {
56
164
  const checks = [];
57
165
 
58
166
  // Cross-check, not two independent presence checks. The old code asked
@@ -164,5 +272,9 @@ export function verify({ claudeDir, userSettingsPath, expectedHookCommands, proj
164
272
  checks.push({ name: `skill ${skill} installed`, ok, detail: ok ? undefined : `missing ${join(skillsDir, skill, 'SKILL.md')}` });
165
273
  }
166
274
 
167
- return { ok: checks.every((c) => c.ok), checks };
275
+ checks.push(...freshnessChecks(claudeDir, pkgRoot));
276
+
277
+ // `unknown` is neither a pass nor a failure: it must not turn a healthy
278
+ // install red, and it must not be counted as verified either.
279
+ return { ok: checks.every((c) => c.ok || c.unknown === true), checks };
168
280
  }
@@ -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
- export function aggregate(records) {
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
- if (r.cwd != null) cwdSet.add(r.cwd);
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
- if (r.detail === 'rag' || tool.includes('search_codebase')) search.rag++;
158
- else if (r.detail === 'glob' || tool === 'Glob') search.glob++;
159
- else if (tool === 'Grep') search.grepTool++;
160
- else search.shell++; // Bash-grep / ambiguous — reported, excluded from RAG-share
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
- if (a.search.rag === 0) {
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.1.0",
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
- Fill the slots from detection; delete the slot guide comment; keep the managed
103
- markers. If the file exists, replace only the managed block — hand-written
104
- content outside it survives untouched.
112
+ 1. **Project `./CLAUDE.md`** from `${CLAUDE_SKILL_DIR}/templates/CLAUDE.md.project.tmpl`
113
+ (managed block) plus `CLAUDE.md.project.authored.tmpl` (the half below it).
114
+ Fill the block's slots from detection; delete the slot guide comment; keep the
115
+ managed markers. Seed the authored half **only when creating the file** — if
116
+ the file exists, replace the managed block and nothing else. Never write
117
+ detected facts below the marker, and never write authored prose above it: the
118
+ split is what makes re-running safe.
105
119
  2. **User `~/.claude/CLAUDE.md`** from `${CLAUDE_SKILL_DIR}/templates/CLAUDE.md.user.tmpl`
106
120
  — only if it is missing or clearly untuned. Never overwrite a user-tuned file
107
121
  without asking.
@@ -0,0 +1,16 @@
1
+ <!-- Everything below is yours. tune-context seeds these headings once, when it
2
+ first creates this file, and never rewrites them: re-running the tool
3
+ replaces only the managed block above. Delete whatever you don't need.
4
+ Keep it thin — headings and pointers here, depth in skills. -->
5
+
6
+ ## What this is
7
+
8
+ <!-- fill in: one sentence, what this project is -->
9
+
10
+ ## Architecture
11
+
12
+ <!-- fill in: 3-6 lines, module -> responsibility -->
13
+
14
+ ## Conventions that bite
15
+
16
+ <!-- fill in: only rules that bite (things that break if ignored) -->
@@ -1,10 +1,10 @@
1
1
  <!-- >>> tune-context (managed) >>>
2
- Regenerated by /tune-context. Edit freely OUTSIDE this block — content
3
- outside the markers is never touched. -->
2
+ Regenerated by /tune-context and `tune-context init`. Everything between
3
+ these markers is DERIVED from detection and is rewritten on every run —
4
+ never hand-edit it. Your own content goes BELOW the block, where the tool
5
+ never reaches. -->
4
6
  # CLAUDE.md — {{PROJECT_NAME}}
5
7
 
6
- {{ONE_LINE_WHAT_THIS_IS}}
7
-
8
8
  ## Build / test / lint
9
9
  ```bash
10
10
  {{BUILD_CMD}}
@@ -17,23 +17,14 @@
17
17
  {{MCP_RULES}}
18
18
  Depth lives in skills: `token-efficiency`, `cdd`, `phase-workflow` (and
19
19
  `/caveman` for terse output) — not in this file.
20
-
21
- ## Architecture
22
- {{ARCHITECTURE_BULLETS}}
23
-
24
- ## Conventions that bite
25
- {{CONVENTIONS}}
26
-
27
20
  <!-- <<< tune-context (managed) <<< -->
28
21
 
29
22
  <!--
30
- Slot guide (delete after filling):
23
+ Slot guide (managed block only — all of these are filled from detection):
31
24
  PROJECT_NAME package/repo name
32
- ONE_LINE_WHAT_THIS_IS one sentence, what the project is
33
25
  BUILD_CMD/TEST_CMD/LINT_CMD verified commands (must actually exist)
34
26
  RUN_NOTES how to run locally, if non-obvious
35
27
  MCP_RULES one line per detected MCP server (see tune-context skill)
36
- ARCHITECTURE_BULLETS 3-6 lines, module -> responsibility
37
- CONVENTIONS only rules that bite (things that break if ignored)
38
- Keep this file thin: headings + pointers. Depth goes into skills.
28
+ Sections you write by hand are seeded once from CLAUDE.md.project.authored.tmpl
29
+ and live BELOW the block. Nothing outside the markers is ever regenerated.
39
30
  -->