Skip to content

Commit 55ef0d8

Browse files
committed
feat: real plugin events from Claude Code hooks; remove showcase mock loop
- scripts/live.ts: strip continuous showcase loop. The 7-phase verification remains; bare `npx tsx scripts/live.ts` exits ~15s. No more synthetic cockpit animation. - scripts/hooks/claude-code-emit.mjs: per-session plugin-state.json accumulates tool counts, errors, file access, anchor intent. Each hook now emits derived plugin events alongside the base mcp.* / lifecycle.*: PreToolUse -> + crow.trust.scored (Bayesian posterior_mean) PostToolUse -> + gorgon.hotspot (rate-limited 1/5), naga.spec_check & lich.review (stub-clean on Edit/Write) UserPromptSubmit -> + djinn.anchor.set (first prompt) / djinn.drift.observed (subsequent) + emu.context_update (turn budget) SessionEnd -> resets plugin-state for next session - inspector/src/lib.rs: bare `enchanter` no longer falls through to the showcase loop or demo mode when hooks aren't installed. Prints clear guidance naming three options and exits 0. - docs/claude-code-integration.md: derived-event mapping table; notes that naga/lich are stubs pending diff parsing, gorgon emits ~once/5, emu budget is hardcoded 200 turns until v0.7 reads ~/.claude.json. - 4 new tests covering crow trust derivation, djinn anchor/drift, emu turn budget, and naga+lich on Edit (5->9 tests in the hooks suite).
1 parent b04cb40 commit 55ef0d8

5 files changed

Lines changed: 394 additions & 196 deletions

File tree

docs/claude-code-integration.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,42 @@ emitter resolves the cache base the same way the inspector does:
4949
| `SessionEnd` | `session.closed` |
5050
| `PreCompact` | `phase.entered` (phase=cross-session, plugin=compactor) |
5151

52+
## Derived plugin events
53+
54+
Each hook also emits derived events that drive the cockpit's PLUGINS table
55+
from real session activity. These are computed from a per-session state
56+
file at `~/.cache/enchanter/plugin-state.json` (or `%LOCALAPPDATA%\...` on
57+
Windows) that accumulates tool counts, error counts, file access, and the
58+
session anchor across hook firings. The state file is rewritten atomically
59+
(write tmp + rename) and reset on `SessionEnd`.
60+
61+
| Hook | Derived event(s) |
62+
|---------------------|------------------------------------------------------------------------|
63+
| `UserPromptSubmit` | `djinn.anchor.set` (first prompt only) — locks `anchor_intent` (≤200 chars) |
64+
| | `djinn.drift.observed` (subsequent prompts) — word-overlap drift vs. anchor, capped at 0.5 |
65+
| | `emu.context_update``turn_estimate = max(12, 200 - turn_count)`, `context_size = prompt_chars` |
66+
| `PreToolUse` | `crow.trust.scored``posterior_mean = 1 - errors/total` per tool, `observation_count = total` |
67+
| `PostToolUse` | `gorgon.hotspot` — top file by access count, `heat = count/total`. **Rate-limited to once every 5 PostToolUse events** to avoid flooding |
68+
| | `naga.spec_check` — Edit/Write only. **Stub-clean verdict** — real algorithm requires diff parsing (deferred to a future release) |
69+
| | `lich.review` — Edit/Write only. **Stub-clean verdict** — same caveat as naga |
70+
71+
### Notes
72+
73+
- **emu's "turns left"**: derived from `200 - turn_count` where `turn_count`
74+
is the number of `UserPromptSubmit` events seen this session. The 200-turn
75+
budget is hardcoded; v0.7 will pull session quotas from `~/.claude.json`.
76+
- **gorgon rate-limit**: 5-event cadence chosen to balance signal vs. noise.
77+
Edit/Write/Read activity tends to cluster, so emitting on every PostToolUse
78+
would spam the cockpit; less frequent than 5 makes the heat-map feel stale.
79+
- **naga + lich are stubs**: both emit `status: "clean"` unconditionally
80+
on Edit/Write. The real algorithms (drift detection vs. spec, sandbox-depth
81+
audit) need the actual diff content, which the hook payload doesn't provide
82+
in a usable form. Verdicts are visual placeholders until the diff parser
83+
lands.
84+
- **crow trust accumulates within a session**: the posterior is reset every
85+
time the cache file disappears (SessionEnd, manual delete). Cross-session
86+
trust would need a second persistent store — out of scope for v0.6.
87+
5288
## Disable
5389

5490
Re-run the installer with `--uninstall`:

inspector/src/lib.rs

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,16 @@ fn claude_code_hook_jsonl() -> PathBuf {
172172
/// cache dir exists (hooks installed but no events yet) → tail it.
173173
/// This is the "real Claude Code work" path — every tool call, prompt,
174174
/// session boundary lights up the cockpit from authentic hook output.
175-
/// 3. stdin is a TTY AND `scripts/live.ts` is reachable from cwd → boot
176-
/// the showcase runtime. Fallback when hooks aren't wired up.
177-
/// 4. stdin is a TTY AND nothing else → demo mode (synthetic emitter).
175+
/// 3. stdin is a TTY AND no hooks installed → print a clear message naming
176+
/// three options and exit 0. We deliberately do NOT fall through to a
177+
/// synthetic showcase loop — confusing real-vs-synthetic data is worse
178+
/// than not opening the cockpit. `enchanter live` and `enchanter inspect`
179+
/// remain explicit opt-ins for advanced users (monorepo dev / pipe mode).
180+
///
181+
/// Note: `src/demo.rs` is now legacy fallback only — the synthetic emitter
182+
/// is no longer wired by default. It still triggers if `enchanter inspect`
183+
/// runs and stdin is a TTY (an unusual user setup) but bare `enchanter`
184+
/// no longer routes there.
178185
fn default_command() -> Command {
179186
use std::io::IsTerminal;
180187
if !std::io::stdin().is_terminal() {
@@ -202,13 +209,18 @@ fn default_command() -> Command {
202209
});
203210
}
204211

205-
// Showcase fallback when running from the monorepo with the demo script.
206-
if std::path::Path::new("scripts/live.ts").is_file() {
207-
return Command::Live(LiveArgs::default());
208-
}
209-
210-
// Last-resort synthetic demo (handled by app::run when stdin is TTY).
211-
Command::Inspect(InspectArgs::default())
212+
// No hooks → print guidance and exit. Better than launching a synthetic
213+
// demo and confusing the user about real-vs-fake data.
214+
eprintln!(
215+
"[enchanter] No Claude Code hooks installed. Three options:\n \
216+
1. Install hooks (recommended for real usage):\n \
217+
cd <enchanter-dir> && node scripts/hooks/install-hooks.mjs\n \
218+
2. Pipe events manually:\n \
219+
<runtime> | enchanter\n \
220+
3. Replay a captured JSONL:\n \
221+
enchanter inspect --from <file.jsonl>"
222+
);
223+
std::process::exit(0);
212224
}
213225

214226
/// Library entry point invoked from `main`.

scripts/hooks/claude-code-emit.mjs

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ function resolveCacheBase() {
5151
const cacheDir = path.join(resolveCacheBase(), 'enchanter');
5252
const outPath = path.join(cacheDir, 'claude-code.jsonl');
5353
const errPath = path.join(cacheDir, 'claude-code.err');
54+
const stateFile = path.join(cacheDir, 'plugin-state.json');
5455

5556
// --------------------------------------------------------------------------
5657
// Logging helpers — never throw, never touch stdout.
@@ -229,6 +230,108 @@ async function readStdinJson() {
229230
});
230231
}
231232

233+
// --------------------------------------------------------------------------
234+
// Per-session plugin-state — accumulates across hook invocations within one
235+
// Claude Code session so derived events (crow trust posterior, gorgon
236+
// hotspot file, djinn anchor drift, emu turn budget) can be computed from
237+
// real history. Schema is documented in docs/claude-code-integration.md.
238+
// File is rewritten atomically (write tmp + rename) so concurrent hook
239+
// firings don't tear the JSON. Reset at SessionEnd.
240+
// --------------------------------------------------------------------------
241+
function makeFreshState(sessionId) {
242+
return {
243+
session_id: sessionId || '',
244+
turn_count: 0,
245+
tool_counts: {},
246+
tool_errors: {},
247+
file_access_counts: {},
248+
anchor_intent: '',
249+
last_prompt_text: '',
250+
started_at: nowSec(),
251+
// Counter so gorgon emits roughly every 5 events (rate limit).
252+
gorgon_tick: 0,
253+
};
254+
}
255+
256+
function readState(sessionId) {
257+
try {
258+
const raw = fs.readFileSync(stateFile, 'utf8');
259+
const parsed = JSON.parse(raw);
260+
if (parsed && typeof parsed === 'object') {
261+
// If the cached state is from a different session, start fresh.
262+
if (sessionId && parsed.session_id && parsed.session_id !== sessionId) {
263+
return makeFreshState(sessionId);
264+
}
265+
// Backfill missing fields if older state file exists.
266+
const fresh = makeFreshState(sessionId);
267+
return { ...fresh, ...parsed };
268+
}
269+
} catch {
270+
/* missing or corrupt — fall through to fresh */
271+
}
272+
return makeFreshState(sessionId);
273+
}
274+
275+
function writeState(state) {
276+
try {
277+
ensureCacheDir();
278+
const tmp = stateFile + '.tmp';
279+
fs.writeFileSync(tmp, JSON.stringify(state));
280+
fs.renameSync(tmp, stateFile);
281+
} catch (err) {
282+
logError('plugin-state write failed', err);
283+
}
284+
}
285+
286+
function resetState() {
287+
try {
288+
fs.unlinkSync(stateFile);
289+
} catch {
290+
/* fine if missing */
291+
}
292+
}
293+
294+
// Word-overlap drift: 1.0 - (overlap / max(words(a), words(b))). Capped at 0.5.
295+
function computeDrift(anchorText, currentText) {
296+
const tok = (s) =>
297+
String(s || '')
298+
.toLowerCase()
299+
.split(/[^a-z0-9]+/i)
300+
.filter((w) => w.length >= 3);
301+
const a = new Set(tok(anchorText));
302+
const b = new Set(tok(currentText));
303+
if (a.size === 0 || b.size === 0) return 0;
304+
let overlap = 0;
305+
for (const w of a) if (b.has(w)) overlap += 1;
306+
const denom = Math.max(a.size, b.size);
307+
const drift = 1.0 - overlap / denom;
308+
return Math.min(0.5, Math.max(0, drift));
309+
}
310+
311+
// Best-effort: pull a file path off Claude Code's tool_input shape.
312+
// Edit/Read/Write all use `file_path`; NotebookEdit uses `notebook_path`;
313+
// Bash has no canonical file arg.
314+
function extractFilePath(toolName, toolInput) {
315+
if (!toolInput || typeof toolInput !== 'object') return null;
316+
const candidates = ['file_path', 'notebook_path', 'path'];
317+
for (const k of candidates) {
318+
const v = toolInput[k];
319+
if (typeof v === 'string' && v.length > 0) return v;
320+
}
321+
return null;
322+
}
323+
324+
// Inspect tool_response for an error signal.
325+
function isErrorResponse(response) {
326+
if (!response) return false;
327+
if (typeof response === 'string') return false;
328+
if (typeof response !== 'object') return false;
329+
if (response.error) return true;
330+
if (typeof response.status === 'string' && response.status !== 'ok') return true;
331+
if (response.is_error === true) return true;
332+
return false;
333+
}
334+
232335
// --------------------------------------------------------------------------
233336
// Event mapping.
234337
// --------------------------------------------------------------------------
@@ -267,6 +370,52 @@ function emitForHook(eventName, payload) {
267370
payload: { prompt_chars: prompt.length },
268371
}),
269372
);
373+
374+
// Derived plugin events ----------------------------------------------
375+
const state = readState(session_id);
376+
const isFirstPrompt = !state.anchor_intent;
377+
if (isFirstPrompt) {
378+
state.anchor_intent = prompt.slice(0, 200);
379+
// djinn.anchor.set on the first user prompt of the session — locks
380+
// the session intent that subsequent prompts get measured against.
381+
appendEvent(
382+
base({
383+
type: 'djinn.anchor.set',
384+
plugin: 'djinn',
385+
phase: 'anchor',
386+
intent: state.anchor_intent,
387+
}),
388+
);
389+
} else {
390+
// Subsequent prompts → drift relative to the locked anchor.
391+
const drift = computeDrift(state.anchor_intent, prompt);
392+
appendEvent(
393+
base({
394+
type: 'djinn.drift.observed',
395+
plugin: 'djinn',
396+
phase: 'post-session',
397+
drift,
398+
intent: state.anchor_intent,
399+
}),
400+
);
401+
}
402+
state.last_prompt_text = prompt.slice(0, 200);
403+
state.turn_count += 1;
404+
405+
// emu.context_update — turns LEFT in a 200-turn budget, floored at 12
406+
// so the cockpit never flashes 0 (matches the live.ts demo behavior).
407+
const turnEstimate = Math.max(12, 200 - state.turn_count);
408+
appendEvent(
409+
base({
410+
type: 'emu.context_update',
411+
plugin: 'emu',
412+
phase: 'pre-dispatch',
413+
turn_estimate: turnEstimate,
414+
context_size: prompt.length,
415+
}),
416+
);
417+
418+
writeState(state);
270419
break;
271420
}
272421

@@ -282,6 +431,36 @@ function emitForHook(eventName, payload) {
282431
payload: { args: truncArgs(toolInput) },
283432
}),
284433
);
434+
435+
// Derived plugin events ----------------------------------------------
436+
const state = readState(session_id);
437+
const total = (state.tool_counts[toolName] || 0) + 1;
438+
const errors = state.tool_errors[toolName] || 0;
439+
state.tool_counts[toolName] = total;
440+
441+
// crow.trust.scored — Bayesian posterior_mean from observed errors.
442+
// Uniform prior 0.5 when total <= 0 (impossible here since we just
443+
// bumped it, so this branch is documentation for behavior).
444+
const posteriorMean = total > 0 ? 1.0 - errors / total : 0.5;
445+
appendEvent(
446+
base({
447+
type: 'crow.trust.scored',
448+
plugin: 'crow',
449+
phase: 'trust-gate',
450+
tool_name: toolName,
451+
posterior_mean: posteriorMean,
452+
observation_count: total,
453+
}),
454+
);
455+
456+
// Track file access — fuels gorgon.hotspot on PostToolUse.
457+
const filePath = extractFilePath(toolName, toolInput);
458+
if (filePath) {
459+
state.file_access_counts[filePath] =
460+
(state.file_access_counts[filePath] || 0) + 1;
461+
}
462+
463+
writeState(state);
285464
break;
286465
}
287466

@@ -337,6 +516,77 @@ function emitForHook(eventName, payload) {
337516
);
338517
}
339518
}
519+
520+
// Derived plugin events ----------------------------------------------
521+
const state = readState(session_id);
522+
523+
// Bump error counter if the tool failed — this feeds the next call's
524+
// crow posterior_mean.
525+
if (isErrorResponse(response)) {
526+
state.tool_errors[toolName] = (state.tool_errors[toolName] || 0) + 1;
527+
}
528+
529+
const filePath = extractFilePath(toolName, payload.tool_input);
530+
const isMutator = toolName === 'Edit' || toolName === 'Write';
531+
const isReader = toolName === 'Read';
532+
533+
// gorgon.hotspot — rate-limited to ~once every 5 hooks. Reports the
534+
// currently-hottest file from accumulated access counts. Skipped when
535+
// we have no access data yet (early in the session).
536+
state.gorgon_tick += 1;
537+
if (state.gorgon_tick % 5 === 0) {
538+
let topFile = null;
539+
let topCount = 0;
540+
let total = 0;
541+
for (const [f, c] of Object.entries(state.file_access_counts)) {
542+
total += c;
543+
if (c > topCount) {
544+
topCount = c;
545+
topFile = f;
546+
}
547+
}
548+
if (topFile && total > 0) {
549+
appendEvent(
550+
base({
551+
type: 'gorgon.hotspot',
552+
plugin: 'gorgon',
553+
phase: 'cross-session',
554+
file: topFile,
555+
heat: topCount / total,
556+
}),
557+
);
558+
}
559+
}
560+
561+
// naga + lich — stub-clean verdicts on Edit/Write. Real spec/sandbox
562+
// analysis requires diff parsing (deferred); these stubs let the
563+
// PLUGINS table light up on real edit activity.
564+
if ((isMutator || isReader) && filePath) {
565+
if (isMutator) {
566+
appendEvent(
567+
base({
568+
type: 'naga.spec_check',
569+
plugin: 'naga',
570+
phase: 'post-response',
571+
file: filePath,
572+
status: 'clean',
573+
drift: 0,
574+
}),
575+
);
576+
appendEvent(
577+
base({
578+
type: 'lich.review',
579+
plugin: 'lich',
580+
phase: 'post-response',
581+
file: filePath,
582+
sandbox_depth: 0,
583+
status: 'clean',
584+
}),
585+
);
586+
}
587+
}
588+
589+
writeState(state);
340590
break;
341591
}
342592

@@ -360,6 +610,8 @@ function emitForHook(eventName, payload) {
360610

361611
case 'SessionEnd': {
362612
appendEvent(base({ type: 'session.closed' }));
613+
// Wipe per-session plugin-state so the NEXT session starts clean.
614+
resetState();
363615
break;
364616
}
365617

0 commit comments

Comments
 (0)