@@ -51,6 +51,7 @@ function resolveCacheBase() {
5151const cacheDir = path . join ( resolveCacheBase ( ) , 'enchanter' ) ;
5252const outPath = path . join ( cacheDir , 'claude-code.jsonl' ) ;
5353const 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 - z 0 - 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