Skip to content

Commit c09568f

Browse files
mcp-tool-shopclaude
andcommitted
feat(hook): debug mode + score logging + env-override floor + shape guard
Stage-C observability for the live hook (HOK-B1..B5), all stderr-only so suppressOutput/fail-silent + the <=200-token budget are untouched. AI_LOADOUT_HOOK=debug explains every silent path and prints top near-misses below the floor; usage events now record score + reason + taskHashSource (so the floor can be re-calibrated from field data and timestamp-fallback events aren't counted as distinct tasks); AI_LOADOUT_MIN_SCORE overrides the 0.3 floor without an edit+cutover; a shape guard distinguishes a malformed index from a missing one. package.json test no longer deadlocks on stdin (node --check) + adds engines. Applied mirror+live byte-identical (hash E45F6BE0...); compensator backup loadout-hook.mjs.bak-pre-hokb-20260616. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent abc4201 commit c09568f

2 files changed

Lines changed: 57 additions & 16 deletions

File tree

apps/hook/loadout-hook.mjs

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,15 @@
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

1521
import { 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

3652
function readStdinSync() {
3753
try {
@@ -53,30 +69,39 @@ function clip(s, n) {
5369
}
5470

5571
async 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(/[-:.TZ]/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(/[-:.TZ]/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: {

apps/hook/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66
"description": "UserPromptSubmit hook that injects pointers to relevant memory entries from ~/.ai-loadout/index.json",
77
"main": "loadout-hook.mjs",
88
"scripts": {
9-
"test": "node loadout-hook.mjs"
9+
"test": "node --check loadout-hook.mjs",
10+
"smoke": "pwsh -NoProfile -File smoke-test.ps1"
11+
},
12+
"engines": {
13+
"node": ">=20"
1014
},
1115
"dependencies": {
1216
"@mcptoolshop/ai-loadout": "^1.4.3"

0 commit comments

Comments
 (0)