77// • Emit AT MOST 5 entries as one-line pointers: "- <id> — <summary> → <path>"
88// • Total additionalContext kept ≤ ~200 tokens
99// • Below-threshold match (score < HOOK_MIN_SCORE) → emit nothing (silent)
10- // • Always-record loaded events to ~/.ai-loadout/usage.jsonl
10+ // • Always-record loaded events to ~/.ai-loadout/usage.jsonl (incl. score + reason)
1111//
12- // Off-switch: AI_LOADOUT_HOOK=off no-op exit 0.
12+ // Switches (env):
13+ // • AI_LOADOUT_HOOK=off → no-op exit 0
14+ // • AI_LOADOUT_HOOK=debug → print why-silent + top near-misses to STDERR only
15+ // (never stdout; the suppressOutput / fail-silent
16+ // contract and the ≤200-token budget are untouched)
17+ // • AI_LOADOUT_MIN_SCORE=<n> → override the score floor (default 0.3) for
18+ // calibration, without an edit + mirror→live cutover
1319// Latency budget: < 500 ms cold. Never blocks.
1420
1521import { readFileSync , appendFileSync , existsSync , mkdirSync } from 'node:fs' ;
@@ -29,9 +35,19 @@ const MAX_LINE_CHARS = 180;
2935// always pass. Calibrated 2026-06-16 against the live 336-entry index: observed
3036// incidental single-keyword noise tops out at ~0.25, genuine topical matches begin
3137// ~0.33+, so 0.3 is the "confident match" floor that delivers the design's
32- // "below-threshold → emit nothing". Recall on keyword-rich entries (e.g. game
38+ // "below-threshold → emit nothing". Override per-run with AI_LOADOUT_MIN_SCORE for
39+ // calibration without an edit + cutover. Recall on keyword-rich entries (e.g. game
3340// canon) is a separate Phase-2 keyword-curation concern, not this floor's job.
34- const HOOK_MIN_SCORE = 0.3 ;
41+ const _envMin = Number ( process . env . AI_LOADOUT_MIN_SCORE ) ;
42+ const HOOK_MIN_SCORE = Number . isFinite ( _envMin ) ? _envMin : 0.3 ;
43+
44+ // Debug diagnostics → STDERR only (never stdout, so suppressOutput + fail-silent
45+ // are preserved). Answers "why was the hook silent on a prompt I expected a hit for".
46+ const DEBUG = process . env . AI_LOADOUT_HOOK === 'debug' ;
47+ function debug ( msg ) {
48+ if ( ! DEBUG ) return ;
49+ try { process . stderr . write ( '[loadout-hook] ' + msg + '\n' ) ; } catch { /* ignore */ }
50+ }
3551
3652function readStdinSync ( ) {
3753 try {
@@ -53,30 +69,39 @@ function clip(s, n) {
5369}
5470
5571async function main ( ) {
56- if ( ! existsSync ( INDEX_PATH ) ) { safeExit ( 0 ) ; return ; }
72+ if ( ! existsSync ( INDEX_PATH ) ) { debug ( 'silent: no index at ' + INDEX_PATH ) ; safeExit ( 0 ) ; return ; }
5773
5874 const stdin = readStdinSync ( ) ;
5975 let payload ;
60- try { payload = JSON . parse ( stdin ) ; } catch { safeExit ( 0 ) ; return ; }
76+ try { payload = JSON . parse ( stdin ) ; } catch { debug ( 'silent: stdin is not valid JSON' ) ; safeExit ( 0 ) ; return ; }
6177 const prompt = ( payload . prompt || payload . user_prompt || payload . message || '' ) . toString ( ) ;
62- if ( ! prompt . trim ( ) ) { safeExit ( 0 ) ; return ; }
78+ if ( ! prompt . trim ( ) ) { debug ( 'silent: empty prompt' ) ; safeExit ( 0 ) ; return ; }
6379
6480 let index ;
65- try { index = JSON . parse ( readFileSync ( INDEX_PATH , 'utf8' ) ) ; } catch { safeExit ( 0 ) ; return ; }
81+ try { index = JSON . parse ( readFileSync ( INDEX_PATH , 'utf8' ) ) ; } catch { debug ( 'silent: index is not valid JSON' ) ; safeExit ( 0 ) ; return ; }
82+ // Shape guard: a valid-but-wrong index would otherwise throw inside matchLoadout
83+ // and look identical to "no index". Surface the difference in debug mode.
84+ if ( ! index || ! Array . isArray ( index . entries ) ) { debug ( 'silent: index parsed but has no entries[] array (malformed)' ) ; safeExit ( 0 ) ; return ; }
6685
6786 let matchLoadout ;
6887 try {
6988 ( { matchLoadout } = await import ( '@mcptoolshop/ai-loadout' ) ) ;
7089 } catch {
90+ debug ( 'silent: could not import @mcptoolshop/ai-loadout' ) ;
7191 safeExit ( 0 ) ; return ;
7292 }
7393
7494 let results ;
75- try { results = matchLoadout ( prompt , index ) ; } catch { safeExit ( 0 ) ; return ; }
76- const top = ( results || [ ] )
77- . filter ( r => r && r . entry && r . entry . priority !== 'manual' && r . score >= HOOK_MIN_SCORE )
78- . slice ( 0 , MAX_ENTRIES ) ;
79- if ( top . length === 0 ) { safeExit ( 0 ) ; return ; }
95+ try { results = matchLoadout ( prompt , index ) ; } catch { debug ( 'silent: matchLoadout threw' ) ; safeExit ( 0 ) ; return ; }
96+ const eligible = ( results || [ ] ) . filter ( r => r && r . entry && r . entry . priority !== 'manual' ) ;
97+ const top = eligible . filter ( r => r . score >= HOOK_MIN_SCORE ) . slice ( 0 , MAX_ENTRIES ) ;
98+ if ( top . length === 0 ) {
99+ if ( DEBUG ) {
100+ const near = eligible . slice ( 0 , 3 ) . map ( r => `${ ( r . score ?? 0 ) . toFixed ( 3 ) } ${ r . entry . id } (${ r . reason || '' } )` ) ;
101+ debug ( `silent: no match ≥ floor ${ HOOK_MIN_SCORE } . top near-misses: ` + ( near . length ? near . join ( ' | ' ) : '(none)' ) ) ;
102+ }
103+ safeExit ( 0 ) ; return ;
104+ }
80105
81106 const lines = top . map ( r => {
82107 const id = r . entry . id || '(unnamed)' ;
@@ -89,22 +114,34 @@ async function main() {
89114 '[loadout-hook] Memory entries relevant to this prompt (open the file pointer before acting, do not paraphrase from the summary):\n' +
90115 lines . join ( '\n' ) ;
91116
92- // Record usage (best-effort; never block)
117+ // Record usage (best-effort; never block). Records score + reason so the floor can
118+ // be re-calibrated from field data (ai-loadout usage/dead), and a taskHashSource
119+ // marker so ungroupable (timestamp-fallback) events aren't counted as distinct tasks.
93120 try {
94121 if ( ! existsSync ( dirname ( USAGE_PATH ) ) ) mkdirSync ( dirname ( USAGE_PATH ) , { recursive : true } ) ;
95122 const ts = new Date ( ) . toISOString ( ) ;
96- const taskHash = ( payload . prompt_id || payload . session_id || '' ) . toString ( ) . slice ( 0 , 12 ) || ts . replace ( / [ - : . T Z ] / g, '' ) . slice ( 0 , 14 ) ;
123+ const sessionId = ( payload . session_id || '' ) . toString ( ) ;
124+ const promptId = ( payload . prompt_id || '' ) . toString ( ) ;
125+ let taskHash , taskHashSource ;
126+ if ( sessionId ) { taskHash = sessionId . slice ( 0 , 12 ) ; taskHashSource = 'session' ; }
127+ else if ( promptId ) { taskHash = promptId . slice ( 0 , 12 ) ; taskHashSource = 'prompt' ; }
128+ else { taskHash = ts . replace ( / [ - : . T Z ] / g, '' ) . slice ( 0 , 14 ) ; taskHashSource = 'timestamp' ; }
97129 const events = top . map ( r => JSON . stringify ( {
98130 timestamp : ts ,
99131 taskHash,
132+ taskHashSource,
100133 entryId : r . entry . id ,
101134 trigger : 'UserPromptSubmit' ,
102135 mode : r . mode || 'lazy' ,
136+ score : typeof r . score === 'number' ? r . score : null ,
137+ reason : r . reason || '' ,
103138 tokensEst : r . entry . tokens_est || 0
104139 } ) ) . join ( '\n' ) + '\n' ;
105140 appendFileSync ( USAGE_PATH , events , 'utf8' ) ;
106141 } catch { /* swallow */ }
107142
143+ if ( DEBUG ) debug ( `injected ${ top . length } pointer(s): ` + top . map ( r => `${ r . entry . id } @${ ( r . score ?? 0 ) . toFixed ( 3 ) } ` ) . join ( ', ' ) ) ;
144+
108145 process . stdout . write ( JSON . stringify ( {
109146 suppressOutput : true ,
110147 hookSpecificOutput : {
0 commit comments