ai-psychosis/hooks/scripts/tool-tracker.mjs
Kjell Tore Guttormsen 7d53a7325e fix(hooks): include required hookEventName in hookSpecificOutput JSON
outputWithContext() emitted a hookSpecificOutput object without the
required hookEventName field, so Claude Code rejected the hook output
("hookSpecificOutput is missing required field hookEventName") and
dropped the context the SessionStart, UserPromptSubmit, and PostToolUse
hooks tried to inject. Thread the event name through the helper; each
caller now passes its own (SessionStart / UserPromptSubmit / PostToolUse).
Existing emission tests now assert hookEventName per event.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmHCQjJHUyWwxGAVVjNLgp
2026-06-24 06:46:11 +02:00

166 lines
5.4 KiB
JavaScript

// Interaction Awareness — PostToolUse hook (Layer 2, Node.js)
// Tracks tool usage, edit ratio, burst detection, session duration.
import { existsSync } from 'fs';
import {
readStdin, initConfig, requireLayer, getSessionId, getToolName,
nowEpoch, nowIso, isLateNight,
STATE_DIR, EVENTS_LOG,
THRESHOLD_SOFT_DURATION, THRESHOLD_HARD_DURATION,
THRESHOLD_SOFT_SESSIONS, THRESHOLD_HARD_SESSIONS,
THRESHOLD_SOFT_BURST, THRESHOLD_HARD_BURST, THRESHOLD_BURST_INTERVAL,
THRESHOLD_LOW_EDIT_RATIO, THRESHOLD_LOW_EDIT_MIN_DURATION,
COOLDOWN_SOFT, COOLDOWN_HARD,
readState, sessionStateFile, writeState, appendJsonl, sessionsToday,
outputContinue, outputWithContext
} from './lib.mjs';
readStdin();
initConfig();
requireLayer(2);
const sid = getSessionId();
const sf = sessionStateFile();
if (!sid || !existsSync(sf)) {
process.stdout.write(JSON.stringify({ continue: true }) + '\n');
process.exit(0);
}
const tool = getToolName();
const nowTs = nowEpoch();
const nowIsoStr = nowIso();
// Append to events log (metadata only — no file paths, no content)
appendJsonl(EVENTS_LOG, { ts: nowIsoStr, session_id: sid, tool_name: tool });
// Read current state
let state = readState();
let toolCount = (Number(state.tool_count) || 0) + 1;
let editCount = Number(state.edit_count) || 0;
const lastEvent = Number(state.last_event_epoch) || 0;
let burstCount = Number(state.burst_count) || 0;
const startEpoch = Number(state.start_epoch) || 0;
const lastWarning = Number(state.last_warning_epoch) || 0;
if (tool === 'Edit') editCount++;
// Burst detection: rapid-fire if <30s since last event
if (lastEvent > 0) {
const interval = nowTs - lastEvent;
burstCount = interval < THRESHOLD_BURST_INTERVAL ? burstCount + 1 : 0;
}
// Write updated state
state.tool_count = toolCount;
state.edit_count = editCount;
state.last_event_epoch = nowTs;
state.burst_count = burstCount;
writeState(state);
// Check thresholds every 25 calls or when burst threshold hit
let shouldCheck = false;
if (toolCount % 25 === 0) shouldCheck = true;
if (burstCount === THRESHOLD_SOFT_BURST || burstCount === THRESHOLD_HARD_BURST) shouldCheck = true;
if (!shouldCheck) {
outputContinue();
process.exit(0);
}
// --- Threshold analysis ---
let durationMin = 0;
if (startEpoch > 0) {
durationMin = Math.floor((nowTs - startEpoch) / 60);
}
let editRatio = 0;
if (toolCount > 0) {
editRatio = Math.floor(editCount * 100 / toolCount);
}
const dayCount = sessionsToday();
// Determine warning level
let level = ''; // 'soft' or 'hard'
const messages = [];
// Duration thresholds
if (durationMin >= THRESHOLD_HARD_DURATION) {
level = 'hard';
const hours = Math.floor(durationMin / 60);
const mins = durationMin % 60;
messages.push(`Session duration: ${hours}h${mins}m.`);
} else if (durationMin >= THRESHOLD_SOFT_DURATION) {
level = 'soft';
messages.push(`Session: ${durationMin} min.`);
}
// Session count
if (dayCount >= THRESHOLD_HARD_SESSIONS) {
level = 'hard';
messages.push(`${dayCount} sessions today.`);
} else if (dayCount > THRESHOLD_SOFT_SESSIONS) {
if (!level) level = 'soft';
messages.push(`${dayCount} sessions today.`);
}
// Burst
if (burstCount >= THRESHOLD_HARD_BURST) {
level = 'hard';
messages.push(`Rapid-fire: ${burstCount} consecutive fast interactions.`);
} else if (burstCount >= THRESHOLD_SOFT_BURST) {
if (!level) level = 'soft';
messages.push(`Rapid-fire: ${burstCount} consecutive fast interactions.`);
}
// Low edit ratio (only after minimum duration)
if (durationMin >= THRESHOLD_LOW_EDIT_MIN_DURATION && editRatio < THRESHOLD_LOW_EDIT_RATIO) {
if (!level) level = 'soft';
messages.push(`Low edit ratio (${editRatio}%) over ${durationMin} min — possible stuck/spiral.`);
}
// Late night check
const late = isLateNight() ? ' Late-night session.' : '';
// No warnings — just periodic reminder at modulo-25
if (!level) {
if (toolCount % 25 === 0) {
outputWithContext('REMINDER (Interaction Awareness): Check your next response against these rules — no unearned affirmations, no reformulating the user\'s words in stronger terms, no skipping counterarguments to stay agreeable. If you detect a reinforcement loop, scope escalation, or narrative crystallization: name it now.', 'PostToolUse');
} else {
outputContinue();
}
process.exit(0);
}
// Determine cooldown
const cooldown = level === 'hard' ? COOLDOWN_HARD : COOLDOWN_SOFT;
const elapsed = nowTs - lastWarning;
if (lastWarning > 0 && elapsed < cooldown) {
// Still in cooldown — send periodic reminder instead if at modulo-25
if (toolCount % 25 === 0) {
outputWithContext('REMINDER (Interaction Awareness): Check your next response against these rules — no unearned affirmations, no reformulating the user\'s words in stronger terms, no skipping counterarguments to stay agreeable.', 'PostToolUse');
} else {
outputContinue();
}
process.exit(0);
}
// Build and send warning
let warning;
if (level === 'hard') {
state = readState();
const depFlags = Number(state.dep_flags) || 0;
warning = `INTERACTION AWARENESS: ${messages.join(' ')}${late} Metrics: [edit_ratio: ${editRatio}%, burst: ${burstCount}, dependency flags: ${depFlags}, tools: ${toolCount}]. Your instructions require you to suggest stopping.`;
} else {
warning = `${messages.join(' ')}${late} Consider a break.`;
}
// Record warning time
state = readState();
state.last_warning_epoch = nowTs;
writeState(state);
outputWithContext(warning, 'PostToolUse');