BRIEF-vurdering-v2.md tiltak 2: "design for the investigator, not the
validator". The hook alerts stated short conclusions a reader can only
accept or dismiss ("Rapid-fire: N consecutive fast interactions",
"possible stuck/spiral", "Consider a break").
Each alert in tool-tracker.mjs now follows the form the read-dominant
edit-ratio message introduced in 2c9e2de — observation, the counter-signal
that changes how to read it, then what to check:
- burst: names the interval and that edits were among the calls, and asks
whether each change was verified before the next
- edit ratio: carries the read percentage and asks what the remaining
calls are doing and whether the approach is converging
- soft warning: closes on framing instead of prescribing a break
- hard warning: asks the model to name its observations and ask what they
reflect; the required stop action is unchanged
commands/interaction-report.md gains the same rule for Observations and
trend reporting, plus an explicit "investigator, not validator" tone rule
and a "report the difference, not a label for it" rule.
Wording only — thresholds, heuristics, data model and required actions
are untouched. README examples and threshold-basis cells updated to match.
Tests first (Iron Law): 6 new/updated assertions on message text in
tests/tool-tracker.test.mjs and tests/interaction-report.test.mjs, red
before the change. node --test tests/*.test.mjs: 269 pass, 5 fail — the
pre-existing perf wall-clock cases only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013U8ZH25KiMtts89yWRuVWD
198 lines
7 KiB
JavaScript
198 lines
7 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,
|
|
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');
|