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
166 lines
5.4 KiB
JavaScript
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');
|