// 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, THRESHOLD_READ_DOMINANT_RATIO, isReadTool, 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; let readCount = Number(state.read_count) || 0; const lastEvent = Number(state.last_event_epoch) || 0; let burstCount = Number(state.burst_count) || 0; // Absent in pre-calibration state files — an unseen run starts read-only. let burstReadOnly = state.burst_read_only !== false; const startEpoch = Number(state.start_epoch) || 0; const lastWarning = Number(state.last_warning_epoch) || 0; const toolIsRead = isReadTool(tool); if (tool === 'Edit') editCount++; if (toolIsRead) readCount++; // Burst detection: rapid-fire if <30s since last event if (lastEvent > 0) { const interval = nowTs - lastEvent; if (interval < THRESHOLD_BURST_INTERVAL) { burstCount++; burstReadOnly = burstReadOnly && toolIsRead; } else { burstCount = 0; burstReadOnly = toolIsRead; } } // Write updated state state.tool_count = toolCount; state.edit_count = editCount; state.read_count = readCount; state.last_event_epoch = nowTs; state.burst_count = burstCount; state.burst_read_only = burstReadOnly; 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 — a run of read-only tools is bulk reading, not fast editing. What // remains is reported as the observation plus what would tell a fast-but- // deliberate run apart from an unchecked one — never as a verdict label. const burstMessage = `Fast tool calls: ${burstCount} in a row under ${THRESHOLD_BURST_INTERVAL}s apart, edits among them — check whether each change was verified before the next one started.`; if (!burstReadOnly) { if (burstCount >= THRESHOLD_HARD_BURST) { level = 'hard'; messages.push(burstMessage); } else if (burstCount >= THRESHOLD_SOFT_BURST) { if (!level) level = 'soft'; messages.push(burstMessage); } } // Low edit ratio (only after minimum duration). A read-dominant session has // a structurally low edit ratio whether or not the work is productive, so it // is reported as context — never as a diagnosis, and never as the sole reason // for a warning. const readRatio = toolCount > 0 ? Math.floor(readCount * 100 / toolCount) : 0; const readDominant = readRatio >= THRESHOLD_READ_DOMINANT_RATIO; if (durationMin >= THRESHOLD_LOW_EDIT_MIN_DURATION && editRatio < THRESHOLD_LOW_EDIT_RATIO) { if (readDominant) { if (level) { messages.push(`Low edit ratio (${editRatio}%) over ${durationMin} min, but ${readRatio}% of tool calls are reads — read-intensive work (research/audit) rather than a stall.`); } } else { if (!level) level = 'soft'; messages.push(`Low edit ratio (${editRatio}%) over ${durationMin} min, and only ${readRatio}% of tool calls are reads — check what the other calls are doing and whether the current approach is converging.`); } } // 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}]. Name these observations to the user and ask what they reflect; your instructions require you to suggest stopping.`; } else { warning = `${messages.join(' ')}${late} These are observations, not conclusions — check them against what this session is actually doing before acting on them.`; } // Record warning time state = readState(); state.last_warning_epoch = nowTs; writeState(state); outputWithContext(warning, 'PostToolUse');