@bglocation/tune-context 1.0.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 +19 -0
- package/.claude-plugin/plugin.json +20 -0
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/agents/explore-cheap.md +18 -0
- package/agents/reviewer.md +18 -0
- package/bin/tune-context.mjs +89 -0
- package/cli/detect.mjs +114 -0
- package/cli/generate.mjs +123 -0
- package/cli/managed-block.mjs +26 -0
- package/cli/sync-doctrine.mjs +29 -0
- package/cli/verify.mjs +52 -0
- package/eval/adoption-report.mjs +244 -0
- package/hooks/adoption-log.mjs +132 -0
- package/hooks/hooks.json +48 -0
- package/hooks/pre-compact-snapshot.mjs +73 -0
- package/hooks/session-start-reinject.mjs +40 -0
- package/package.json +45 -0
- package/skills/caveman/SKILL.md +26 -0
- package/skills/cdd/SKILL.md +50 -0
- package/skills/phase-workflow/SKILL.md +28 -0
- package/skills/token-efficiency/SKILL.md +52 -0
- package/skills/tune-context/SKILL.md +157 -0
- package/skills/tune-context/templates/CLAUDE.md.project.tmpl +39 -0
- package/skills/tune-context/templates/CLAUDE.md.user.tmpl +23 -0
- package/skills/tune-context/templates/settings.json.tmpl +58 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Mirrors this package's skills/agents/hooks into a Claude Code config dir
|
|
2
|
+
// (~/.claude by default). Idempotent — removes each destination entry before
|
|
3
|
+
// copying, so deletions at the source propagate too, not just an overlay.
|
|
4
|
+
import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {string} pkgRoot - root of the tune-context package (contains skills/, agents/, hooks/)
|
|
9
|
+
* @param {string} claudeDir - destination Claude config dir (e.g. ~/.claude)
|
|
10
|
+
* @returns {{kind: string, name: string, dest: string}[]} synced items
|
|
11
|
+
*/
|
|
12
|
+
export function syncDoctrine(pkgRoot, claudeDir) {
|
|
13
|
+
const synced = [];
|
|
14
|
+
for (const kind of ['skills', 'agents', 'hooks']) {
|
|
15
|
+
const from = join(pkgRoot, kind);
|
|
16
|
+
if (!existsSync(from)) continue;
|
|
17
|
+
for (const entry of readdirSync(from)) {
|
|
18
|
+
if (kind === 'hooks' && entry === 'hooks.json') continue; // plugin-only manifest, not a script to install
|
|
19
|
+
const s = join(from, entry);
|
|
20
|
+
const d = join(claudeDir, kind, entry);
|
|
21
|
+
rmSync(d, { recursive: true, force: true });
|
|
22
|
+
mkdirSync(dirname(d), { recursive: true });
|
|
23
|
+
cpSync(s, d, { recursive: true });
|
|
24
|
+
if (kind === 'hooks') chmodSync(d, 0o755);
|
|
25
|
+
synced.push({ kind, name: entry, dest: d });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return synced;
|
|
29
|
+
}
|
package/cli/verify.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Re-checks what generate.mjs claims to have done — "the merge actually
|
|
2
|
+
// landed" per SKILL.md §3. Never trust that a write succeeded just because
|
|
3
|
+
// the function returned; re-read the target file and parse it.
|
|
4
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
function readJson(path) {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @returns {{ok: boolean, checks: {name: string, ok: boolean, detail?: string}[]}}
|
|
17
|
+
*/
|
|
18
|
+
export function verify({ claudeDir, userSettingsPath, expectedHookCommands, projectSettingsPath, expectedPermissions }) {
|
|
19
|
+
const checks = [];
|
|
20
|
+
|
|
21
|
+
const settings = readJson(userSettingsPath);
|
|
22
|
+
for (const { event, scriptName } of expectedHookCommands) {
|
|
23
|
+
const entries = settings?.hooks?.[event] || [];
|
|
24
|
+
const ok = entries.some((g) => g.hooks?.some((h) => h.command?.includes(scriptName)));
|
|
25
|
+
checks.push({ name: `hooks.${event} registers ${scriptName}`, ok, detail: ok ? undefined : `not found in ${userSettingsPath}` });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const scriptNames = [...new Set(expectedHookCommands.map((h) => h.scriptName))];
|
|
29
|
+
for (const scriptName of scriptNames) {
|
|
30
|
+
const scriptPath = join(claudeDir, 'hooks', scriptName);
|
|
31
|
+
const exists = existsSync(scriptPath);
|
|
32
|
+
const executable = exists && Boolean(statSync(scriptPath).mode & 0o111);
|
|
33
|
+
checks.push({ name: `${scriptName} installed + executable`, ok: exists && executable, detail: !exists ? 'missing' : !executable ? 'not executable' : undefined });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (projectSettingsPath && expectedPermissions?.length) {
|
|
37
|
+
const projectSettings = readJson(projectSettingsPath);
|
|
38
|
+
const allow = projectSettings?.permissions?.allow || [];
|
|
39
|
+
for (const perm of expectedPermissions) {
|
|
40
|
+
const ok = allow.includes(perm);
|
|
41
|
+
checks.push({ name: `permissions.allow has ${perm}`, ok, detail: ok ? undefined : `not found in ${projectSettingsPath}` });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const skillsDir = join(claudeDir, 'skills');
|
|
46
|
+
for (const skill of ['token-efficiency', 'caveman', 'cdd', 'phase-workflow']) {
|
|
47
|
+
const ok = existsSync(join(skillsDir, skill, 'SKILL.md'));
|
|
48
|
+
checks.push({ name: `skill ${skill} installed`, ok, detail: ok ? undefined : `missing ${join(skillsDir, skill, 'SKILL.md')}` });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { ok: checks.every((c) => c.ok), checks };
|
|
52
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// adoption-report — the tune-context analog of `rag-usage`. Reads the JSONL
|
|
3
|
+
// that adoption-log.mjs appends (from the PostToolUse + SubagentStart hooks) and
|
|
4
|
+
// prints which token-saving levers the model is actually reaching for: RAG vs
|
|
5
|
+
// grep, doctrine skills, subagents, and re-read churn. Verdict at the end: which
|
|
6
|
+
// levers show adoption, which don't. See tasks/TASK-062.
|
|
7
|
+
//
|
|
8
|
+
// Honest scope (printed in the report too):
|
|
9
|
+
// • Cost is NOT here. Claude Code exposes token cost only via OTEL
|
|
10
|
+
// (`claude_code.cost.usage`, needs a collector) — never a hook. This report
|
|
11
|
+
// measures *behavior* (a proxy), not dollars. OTEL is the opt-in complement.
|
|
12
|
+
// • caveman can't be measured: it's an output style, not a tool call.
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { parseArgs } from 'node:util';
|
|
17
|
+
|
|
18
|
+
const DOCTRINE_SKILLS = new Set([
|
|
19
|
+
'token-efficiency',
|
|
20
|
+
'caveman',
|
|
21
|
+
'cdd',
|
|
22
|
+
'phase-workflow',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
function defaultLogPath() {
|
|
26
|
+
return join(homedir(), '.claude', 'tune-context', 'adoption.jsonl');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const USAGE = `
|
|
30
|
+
adoption-report — which token-saving levers is the model actually using?
|
|
31
|
+
|
|
32
|
+
Usage:
|
|
33
|
+
adoption-report [--log <path>]
|
|
34
|
+
|
|
35
|
+
Options:
|
|
36
|
+
--log <path> Path to adoption.jsonl (default: ~/.claude/tune-context/adoption.jsonl)
|
|
37
|
+
-h, --help Show this help
|
|
38
|
+
|
|
39
|
+
The log is written by the adoption-log.mjs PostToolUse hook (installed by
|
|
40
|
+
tune-context). Cost in dollars is NOT here — that needs OTEL (see report footer).
|
|
41
|
+
`.trim();
|
|
42
|
+
|
|
43
|
+
export function parseLog(text) {
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const line of text.split('\n')) {
|
|
46
|
+
const t = line.trim();
|
|
47
|
+
if (t === '') continue;
|
|
48
|
+
try {
|
|
49
|
+
const o = JSON.parse(t);
|
|
50
|
+
if (o && typeof o === 'object' && typeof o.lever === 'string') out.push(o);
|
|
51
|
+
} catch {
|
|
52
|
+
// malformed line — skip
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function aggregate(records) {
|
|
59
|
+
const search = { rag: 0, text: 0, glob: 0 };
|
|
60
|
+
const skills = new Map();
|
|
61
|
+
const subagents = new Map();
|
|
62
|
+
const readCounts = new Map(); // detail(file) -> times read
|
|
63
|
+
let reads = 0;
|
|
64
|
+
let edits = 0;
|
|
65
|
+
let bash = 0;
|
|
66
|
+
let other = 0;
|
|
67
|
+
|
|
68
|
+
const bump = (m, k) => m.set(k, (m.get(k) ?? 0) + 1);
|
|
69
|
+
|
|
70
|
+
for (const r of records) {
|
|
71
|
+
switch (r.lever) {
|
|
72
|
+
case 'search':
|
|
73
|
+
if (r.detail === 'rag') search.rag++;
|
|
74
|
+
else if (r.detail === 'glob') search.glob++;
|
|
75
|
+
else search.text++;
|
|
76
|
+
break;
|
|
77
|
+
case 'skill':
|
|
78
|
+
bump(skills, r.detail ?? '(unknown)');
|
|
79
|
+
break;
|
|
80
|
+
case 'subagent':
|
|
81
|
+
bump(subagents, r.detail ?? '(unknown)');
|
|
82
|
+
break;
|
|
83
|
+
case 'read':
|
|
84
|
+
reads++;
|
|
85
|
+
if (r.detail) bump(readCounts, r.detail);
|
|
86
|
+
break;
|
|
87
|
+
case 'edit':
|
|
88
|
+
edits++;
|
|
89
|
+
break;
|
|
90
|
+
case 'bash':
|
|
91
|
+
bash++;
|
|
92
|
+
break;
|
|
93
|
+
default:
|
|
94
|
+
other++;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const contentSearches = search.rag + search.text;
|
|
99
|
+
const ragShare = contentSearches > 0 ? search.rag / contentSearches : null;
|
|
100
|
+
|
|
101
|
+
// Re-read churn proxy: files opened more than once, and the extra opens beyond
|
|
102
|
+
// the first. High churn hints the model is re-fetching context it lost — the
|
|
103
|
+
// symptom compaction hooks and RAG are meant to reduce.
|
|
104
|
+
let reReadFiles = 0;
|
|
105
|
+
let redundantReads = 0;
|
|
106
|
+
for (const n of readCounts.values()) {
|
|
107
|
+
if (n > 1) {
|
|
108
|
+
reReadFiles++;
|
|
109
|
+
redundantReads += n - 1;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
total: records.length,
|
|
115
|
+
search,
|
|
116
|
+
ragShare,
|
|
117
|
+
skills: [...skills.entries()].sort((a, b) => b[1] - a[1]),
|
|
118
|
+
subagents: [...subagents.entries()].sort((a, b) => b[1] - a[1]),
|
|
119
|
+
reads,
|
|
120
|
+
edits,
|
|
121
|
+
bash,
|
|
122
|
+
other,
|
|
123
|
+
reReadFiles,
|
|
124
|
+
redundantReads,
|
|
125
|
+
reReadRate: reads > 0 ? redundantReads / reads : null,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function pct(x) {
|
|
130
|
+
return x === null ? 'n/a' : `${Math.round(x * 100)}%`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function formatReport(a) {
|
|
134
|
+
const L = [];
|
|
135
|
+
L.push('tune-context Adoption Report');
|
|
136
|
+
L.push('============================');
|
|
137
|
+
L.push('');
|
|
138
|
+
L.push(`Tool calls logged: ${a.total}`);
|
|
139
|
+
L.push('');
|
|
140
|
+
|
|
141
|
+
L.push('Search — RAG vs grep:');
|
|
142
|
+
L.push(` semantic (RAG): ${a.search.rag}`);
|
|
143
|
+
L.push(` text (grep/find):${String(a.search.text).padStart(2)}`);
|
|
144
|
+
L.push(` glob (file find):${String(a.search.glob).padStart(2)}`);
|
|
145
|
+
L.push(
|
|
146
|
+
` RAG share of content search: ${pct(a.ragShare)}` +
|
|
147
|
+
(a.ragShare === null ? ' (no content searches yet)' : ''),
|
|
148
|
+
);
|
|
149
|
+
L.push('');
|
|
150
|
+
|
|
151
|
+
L.push(`Doctrine skills invoked: ${a.skills.length} distinct`);
|
|
152
|
+
for (const [name, n] of a.skills) {
|
|
153
|
+
const mark = DOCTRINE_SKILLS.has(name) ? '★' : ' ';
|
|
154
|
+
L.push(` ${mark} ${String(n).padStart(3)}× ${name}`);
|
|
155
|
+
}
|
|
156
|
+
if (a.skills.length === 0) L.push(' (none — doctrine skills unused)');
|
|
157
|
+
L.push('');
|
|
158
|
+
|
|
159
|
+
L.push(`Subagents spawned: ${a.subagents.length} distinct`);
|
|
160
|
+
for (const [name, n] of a.subagents) {
|
|
161
|
+
L.push(` ${String(n).padStart(3)}× ${name}`);
|
|
162
|
+
}
|
|
163
|
+
if (a.subagents.length === 0) L.push(' (none)');
|
|
164
|
+
L.push('');
|
|
165
|
+
|
|
166
|
+
L.push('Context churn (re-read proxy):');
|
|
167
|
+
L.push(` reads: ${a.reads} edits: ${a.edits} bash: ${a.bash}`);
|
|
168
|
+
L.push(
|
|
169
|
+
` re-read files: ${a.reReadFiles} redundant opens: ${a.redundantReads}` +
|
|
170
|
+
` (${pct(a.reReadRate)} of reads)`,
|
|
171
|
+
);
|
|
172
|
+
L.push('');
|
|
173
|
+
|
|
174
|
+
// Verdict — behavioral, not a cost claim. Frozen thresholds so the model
|
|
175
|
+
// doesn't grade its own work on a sliding scale (anti-bias, per TASK-062).
|
|
176
|
+
L.push('Verdict (behavioral proxy — freeze thresholds with PO before trusting):');
|
|
177
|
+
if (a.total === 0) {
|
|
178
|
+
L.push(' No data yet. Run some sessions with the hook installed, then re-run.');
|
|
179
|
+
} else {
|
|
180
|
+
if (a.ragShare !== null) {
|
|
181
|
+
L.push(
|
|
182
|
+
a.ragShare >= 0.5
|
|
183
|
+
? ' ✓ RAG lever working — semantic search is the default over grep.'
|
|
184
|
+
: ' ✗ RAG lever weak — grep still dominates content search. Revisit index/skill wiring.',
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
L.push(
|
|
188
|
+
a.skills.length > 0
|
|
189
|
+
? ' ✓ Doctrine skills are being invoked.'
|
|
190
|
+
: ' ✗ Doctrine skills unused — check skill descriptions / discoverability.',
|
|
191
|
+
);
|
|
192
|
+
L.push(
|
|
193
|
+
a.reReadRate !== null && a.reReadRate <= 0.25
|
|
194
|
+
? ' ✓ Low re-read churn.'
|
|
195
|
+
: ' ~ Elevated re-read churn — context may be getting lost (compaction/RAG lever).',
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
L.push('');
|
|
199
|
+
L.push('Cost (dollars) is not measurable from hooks. For hard cost, enable OTEL:');
|
|
200
|
+
L.push(' CLAUDE_CODE_ENABLE_TELEMETRY=1 + an OTLP collector →');
|
|
201
|
+
L.push(' metric `claude_code.cost.usage` (direct) / `claude_code.token.usage`.');
|
|
202
|
+
L.push(' caveman (output style) leaves no tool call — not measured here.');
|
|
203
|
+
|
|
204
|
+
return L.join('\n');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function main() {
|
|
208
|
+
let logPath;
|
|
209
|
+
try {
|
|
210
|
+
const { values } = parseArgs({
|
|
211
|
+
args: process.argv.slice(2),
|
|
212
|
+
options: {
|
|
213
|
+
log: { type: 'string' },
|
|
214
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
215
|
+
},
|
|
216
|
+
strict: true,
|
|
217
|
+
});
|
|
218
|
+
if (values.help) {
|
|
219
|
+
process.stdout.write(`${USAGE}\n`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
logPath = values.log ?? defaultLogPath();
|
|
223
|
+
} catch (e) {
|
|
224
|
+
process.stderr.write(`adoption-report: ${e.message}\n`);
|
|
225
|
+
process.exitCode = 1;
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (!existsSync(logPath)) {
|
|
230
|
+
process.stdout.write(
|
|
231
|
+
'tune-context Adoption Report\n============================\n\n' +
|
|
232
|
+
`No adoption log found at ${logPath}.\n` +
|
|
233
|
+
'(Install the adoption-log.mjs PostToolUse hook via tune-context and run a session.)\n',
|
|
234
|
+
);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const records = parseLog(readFileSync(logPath, 'utf8'));
|
|
239
|
+
process.stdout.write(`${formatReport(aggregate(records))}\n`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (process.argv[1] && process.argv[1].endsWith('adoption-report.mjs')) {
|
|
243
|
+
main();
|
|
244
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Adoption logger — registered under TWO Claude Code hook events (both matcher
|
|
3
|
+
// "*"): `PostToolUse` (every tool call) and `SubagentStart` (subagent spawns).
|
|
4
|
+
// Appends one JSONL line per event to a user-level log so
|
|
5
|
+
// `eval/adoption-report.mjs` can answer "which token-saving lever is the model
|
|
6
|
+
// actually reaching for?" — RAG vs grep, doctrine skills, subagents, re-read
|
|
7
|
+
// churn. See tasks/TASK-062.
|
|
8
|
+
//
|
|
9
|
+
// Why two events: subagents are NOT PostToolUse tools — Claude Code fires the
|
|
10
|
+
// dedicated `SubagentStart`/`SubagentStop` events for them, carrying `agent_type`
|
|
11
|
+
// (not `subagent_type`). Verified against official docs (code.claude.com/docs/en/hooks.md).
|
|
12
|
+
// One script handles both by branching on the payload shape.
|
|
13
|
+
//
|
|
14
|
+
// Honest scope: a hook sees the *event*, not the model's reasoning or its output
|
|
15
|
+
// style. So it can measure RAG-vs-grep, Skill invocations, subagent spawns and
|
|
16
|
+
// repeated reads — but NOT caveman (an output style, no tool call) and NOT token
|
|
17
|
+
// cost (Claude Code exposes cost only via OTEL `claude_code.cost.usage`, which
|
|
18
|
+
// needs a collector — never lands in a hook). The report is explicit about both
|
|
19
|
+
// gaps. This is a behavioral proxy, always-on and zero-infra; the OTEL path is
|
|
20
|
+
// the opt-in complement for hard cost.
|
|
21
|
+
//
|
|
22
|
+
// Never writes to stdout (would surface as transcript noise) and every failure
|
|
23
|
+
// is swallowed — logging must never block or slow a tool call.
|
|
24
|
+
import {
|
|
25
|
+
appendFileSync,
|
|
26
|
+
mkdirSync,
|
|
27
|
+
readFileSync,
|
|
28
|
+
renameSync,
|
|
29
|
+
statSync,
|
|
30
|
+
} from 'node:fs';
|
|
31
|
+
import { homedir } from 'node:os';
|
|
32
|
+
import { dirname, join } from 'node:path';
|
|
33
|
+
|
|
34
|
+
const MAX_LOG_BYTES = 5 * 1024 * 1024; // rotate to <path>.1 past this (bounded ~2×)
|
|
35
|
+
|
|
36
|
+
function readStdin() {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(readFileSync(0, 'utf8'));
|
|
39
|
+
} catch {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function logFilePath() {
|
|
45
|
+
return join(homedir(), '.claude', 'tune-context', 'adoption.jsonl');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Detect a text-search shell command (grep/rg/find/ag/ack), the grep-side of the
|
|
49
|
+
// RAG-vs-grep ratio. Word-boundary matched so "pgrep"/"configure" don't trip it.
|
|
50
|
+
const TEXT_SEARCH_RE = /(^|[|&;]|\s)(grep|rg|egrep|fgrep|ag|ack|find)\b/;
|
|
51
|
+
|
|
52
|
+
// Map a raw (tool_name, tool_input) into a coarse lever + optional detail.
|
|
53
|
+
// `detail` is deliberately bounded — never the full Bash command (size/privacy).
|
|
54
|
+
// Note: subagents are NOT handled here — they arrive as SubagentStart events, not
|
|
55
|
+
// PostToolUse tool calls (see buildRecord). `Skill`'s skill name is in
|
|
56
|
+
// `tool_input.skill` (the tool's actual parameter; docs don't spell the field).
|
|
57
|
+
export function classify(toolName, toolInput) {
|
|
58
|
+
const input = toolInput ?? {};
|
|
59
|
+
if (toolName === 'Skill') {
|
|
60
|
+
return { lever: 'skill', detail: input.skill ?? null };
|
|
61
|
+
}
|
|
62
|
+
if (typeof toolName === 'string' && toolName.includes('search_codebase')) {
|
|
63
|
+
return { lever: 'search', detail: 'rag' };
|
|
64
|
+
}
|
|
65
|
+
if (toolName === 'Grep') return { lever: 'search', detail: 'text' };
|
|
66
|
+
if (toolName === 'Glob') return { lever: 'search', detail: 'glob' };
|
|
67
|
+
if (toolName === 'Bash') {
|
|
68
|
+
const cmd = typeof input.command === 'string' ? input.command : '';
|
|
69
|
+
if (TEXT_SEARCH_RE.test(cmd)) return { lever: 'search', detail: 'text' };
|
|
70
|
+
return { lever: 'bash', detail: null };
|
|
71
|
+
}
|
|
72
|
+
if (toolName === 'Read') {
|
|
73
|
+
return { lever: 'read', detail: input.file_path ?? null };
|
|
74
|
+
}
|
|
75
|
+
if (toolName === 'Edit' || toolName === 'Write' || toolName === 'MultiEdit') {
|
|
76
|
+
return { lever: 'edit', detail: input.file_path ?? null };
|
|
77
|
+
}
|
|
78
|
+
return { lever: 'other', detail: typeof toolName === 'string' ? toolName : null };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function buildRecord(input) {
|
|
82
|
+
const base = {
|
|
83
|
+
ts: new Date().toISOString(),
|
|
84
|
+
session: input.session_id ?? null,
|
|
85
|
+
cwd: input.cwd ?? null,
|
|
86
|
+
};
|
|
87
|
+
// Subagent event (SubagentStart/Stop): carries agent_type, not tool_name.
|
|
88
|
+
if (input.agent_type != null || isSubagentEvent(input.hook_event_name)) {
|
|
89
|
+
return {
|
|
90
|
+
...base,
|
|
91
|
+
tool: input.hook_event_name ?? 'SubagentStart',
|
|
92
|
+
lever: 'subagent',
|
|
93
|
+
detail: input.agent_type ?? null,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const toolName = input.tool_name ?? null;
|
|
97
|
+
const { lever, detail } = classify(toolName, input.tool_input);
|
|
98
|
+
return { ...base, tool: toolName, lever, detail };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isSubagentEvent(name) {
|
|
102
|
+
return typeof name === 'string' && name.startsWith('Subagent');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function rotateIfNeeded(path) {
|
|
106
|
+
try {
|
|
107
|
+
if (statSync(path).size >= MAX_LOG_BYTES) renameSync(path, `${path}.1`);
|
|
108
|
+
} catch {
|
|
109
|
+
// no file yet, or rotate failed — nothing to do
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function main() {
|
|
114
|
+
try {
|
|
115
|
+
const input = readStdin();
|
|
116
|
+
// Accept a PostToolUse payload (tool_name) or a subagent event (agent_type /
|
|
117
|
+
// hook_event_name Subagent*); ignore anything else (e.g. empty stdin).
|
|
118
|
+
if (!input.tool_name && input.agent_type == null && !isSubagentEvent(input.hook_event_name)) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const path = logFilePath();
|
|
122
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
123
|
+
rotateIfNeeded(path);
|
|
124
|
+
appendFileSync(path, `${JSON.stringify(buildRecord(input))}\n`);
|
|
125
|
+
} catch {
|
|
126
|
+
// non-fatal by design — never block a tool call over a log write
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (process.argv[1] && process.argv[1].endsWith('adoption-log.mjs')) {
|
|
131
|
+
main();
|
|
132
|
+
}
|
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"PreCompact": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "*",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pre-compact-snapshot.mjs\""
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"SessionStart": [
|
|
15
|
+
{
|
|
16
|
+
"matcher": "compact",
|
|
17
|
+
"hooks": [
|
|
18
|
+
{
|
|
19
|
+
"type": "command",
|
|
20
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-start-reinject.mjs\""
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"PostToolUse": [
|
|
26
|
+
{
|
|
27
|
+
"matcher": "*",
|
|
28
|
+
"hooks": [
|
|
29
|
+
{
|
|
30
|
+
"type": "command",
|
|
31
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/adoption-log.mjs\""
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"SubagentStart": [
|
|
37
|
+
{
|
|
38
|
+
"matcher": "*",
|
|
39
|
+
"hooks": [
|
|
40
|
+
{
|
|
41
|
+
"type": "command",
|
|
42
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/adoption-log.mjs\""
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PreCompact hook: writes a mechanical snapshot to a user-level file before
|
|
3
|
+
// Claude Code compacts context (manual /compact or auto-compact). Paired with
|
|
4
|
+
// session-start-reinject.mjs, which reads this file back on the SessionStart
|
|
5
|
+
// event that follows a compaction (matcher: "compact") and injects it via
|
|
6
|
+
// hookSpecificOutput.additionalContext. See tasks/TASK-061.
|
|
7
|
+
//
|
|
8
|
+
// Honest scope: a hook is a plain script with no model access, so it can only
|
|
9
|
+
// capture what the filesystem/git already know (branch, dirty files, recent
|
|
10
|
+
// commits) — not decisions or open threads from the conversation. It is a
|
|
11
|
+
// safety net against losing that mechanical state, not a summary.
|
|
12
|
+
import { execFileSync } from 'node:child_process';
|
|
13
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
function readStdin() {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(readFileSync(0, 'utf8'));
|
|
20
|
+
} catch {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function git(cwd, args) {
|
|
26
|
+
try {
|
|
27
|
+
return execFileSync('git', args, {
|
|
28
|
+
cwd,
|
|
29
|
+
encoding: 'utf8',
|
|
30
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
31
|
+
}).trim();
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function stateFilePath(cwd) {
|
|
38
|
+
const safe = cwd.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
39
|
+
return join(homedir(), '.claude', 'tune-context', 'state', `${safe}.md`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function buildSnapshot(input) {
|
|
43
|
+
const cwd = input.cwd || process.cwd();
|
|
44
|
+
const branch = git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
45
|
+
const status = git(cwd, ['status', '--short']);
|
|
46
|
+
const log = git(cwd, ['log', '-5', '--oneline']);
|
|
47
|
+
const lines = [
|
|
48
|
+
'# tune-context: pre-compact snapshot',
|
|
49
|
+
'',
|
|
50
|
+
`mechanical only — no decisions/open threads (hook has no model access)`,
|
|
51
|
+
'',
|
|
52
|
+
`- when: ${new Date().toISOString()}`,
|
|
53
|
+
`- trigger: ${input.trigger ?? 'unknown'}`,
|
|
54
|
+
`- session: ${input.session_id ?? 'unknown'}`,
|
|
55
|
+
`- cwd: ${cwd}`,
|
|
56
|
+
branch !== null ? `- branch: ${branch}` : null,
|
|
57
|
+
'',
|
|
58
|
+
status ? '## git status --short\n```\n' + status + '\n```' : null,
|
|
59
|
+
log ? '## git log -5 --oneline\n```\n' + log + '\n```' : null,
|
|
60
|
+
].filter((l) => l !== null);
|
|
61
|
+
return lines.join('\n') + '\n';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function main() {
|
|
65
|
+
const input = readStdin();
|
|
66
|
+
const path = stateFilePath(input.cwd || process.cwd());
|
|
67
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
68
|
+
writeFileSync(path, buildSnapshot(input));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (process.argv[1] && process.argv[1].endsWith('pre-compact-snapshot.mjs')) {
|
|
72
|
+
main();
|
|
73
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SessionStart hook (matcher: "compact"): reads the snapshot pre-compact-snapshot.mjs
|
|
3
|
+
// wrote just before this compaction and re-injects it via
|
|
4
|
+
// hookSpecificOutput.additionalContext, per the round-trip confirmed in TASK-056.
|
|
5
|
+
// No-ops (no stdout) when source isn't "compact" or no snapshot exists — never
|
|
6
|
+
// injects stale/wrong-project content.
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { stateFilePath } from './pre-compact-snapshot.mjs';
|
|
9
|
+
|
|
10
|
+
function readStdin() {
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(readFileSync(0, 'utf8'));
|
|
13
|
+
} catch {
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function main() {
|
|
19
|
+
const input = readStdin();
|
|
20
|
+
if (input.source !== 'compact') return;
|
|
21
|
+
const cwd = input.cwd || process.cwd();
|
|
22
|
+
const path = stateFilePath(cwd);
|
|
23
|
+
if (!existsSync(path)) return;
|
|
24
|
+
const additionalContext = readFileSync(path, 'utf8');
|
|
25
|
+
process.stdout.write(
|
|
26
|
+
JSON.stringify({
|
|
27
|
+
hookSpecificOutput: {
|
|
28
|
+
hookEventName: 'SessionStart',
|
|
29
|
+
additionalContext,
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (
|
|
36
|
+
process.argv[1] &&
|
|
37
|
+
process.argv[1].endsWith('session-start-reinject.mjs')
|
|
38
|
+
) {
|
|
39
|
+
main();
|
|
40
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bglocation/tune-context",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Context-efficiency configurator for Claude Code — 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
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Szymon Walczak",
|
|
8
|
+
"homepage": "https://bglocation.dev",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://gitlab.com/bglocation/tune-context.git"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"claude-code",
|
|
15
|
+
"claude",
|
|
16
|
+
"skills",
|
|
17
|
+
"plugin",
|
|
18
|
+
"context-engineering",
|
|
19
|
+
"token-efficiency",
|
|
20
|
+
"configurator",
|
|
21
|
+
"mcp"
|
|
22
|
+
],
|
|
23
|
+
"bin": {
|
|
24
|
+
"tune-context": "./bin/tune-context.mjs"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
".claude-plugin",
|
|
28
|
+
"bin",
|
|
29
|
+
"cli",
|
|
30
|
+
"skills",
|
|
31
|
+
"agents",
|
|
32
|
+
"hooks",
|
|
33
|
+
"eval",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"verify:pack": "node scripts/verify-pack.mjs",
|
|
39
|
+
"smoke:init": "node scripts/smoke-init.mjs",
|
|
40
|
+
"prepublishOnly": "npm run verify:pack && npm run smoke:init"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
}
|
|
45
|
+
}
|