fix(hooks): differentiate burst and edit-ratio heuristics on tool type

The burst counter incremented on any tool call <30s apart regardless of
tool type, so a bulk read of many files produced the same "Rapid-fire"
alert as a rapid-fire editing sequence. The edit-ratio check reported
"possible stuck/spiral" for any session under 10% edits past 30 minutes,
which a read-heavy analysis session satisfies structurally whether or not
the work is productive. Both mechanisms are confirmed in
docs/BRIEF-vurdering-v2.md, verification points 1a and 1b.

tool-tracker.mjs now tracks whether the current burst run consists only
of read tools and suppresses the rapid-fire alert for such runs. A
read-dominant session (>=70% reads) reports its low edit ratio as
context rather than as a stuck/spiral claim, and never as the sole
reason for a warning. Bursts involving Edit/Write/Bash and
non-read-dominant sessions behave exactly as before.

No new data is recorded: the differentiation uses tool_name, which
events.jsonl already logs. The privacy design is untouched.

Tests first (Iron Law): 5 cases in tests/tool-tracker.test.mjs, 3 of
which failed against the previous implementation, 2 of which are
regression guards that passed before and still pass.

Verified: node --test tests/*.test.mjs -> 263 pass, 5 fail. All 5
failures are the perf wall-clock assertions, which fail identically on
HEAD without this change (measured by stashing and re-running) — they
are a pre-existing property of this machine, not a regression.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wMhJuPFUiWqKLSQjrzCXP
This commit is contained in:
Kjell Tore Guttormsen 2026-08-13 20:59:42 +02:00
commit 2c9e2de00a
5 changed files with 141 additions and 13 deletions

View file

@ -10,6 +10,7 @@ import {
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
@ -38,24 +39,37 @@ appendJsonl(EVENTS_LOG, { ts: nowIsoStr, session_id: sid, tool_name: tool });
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;
burstCount = interval < THRESHOLD_BURST_INTERVAL ? burstCount + 1 : 0;
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
@ -106,19 +120,33 @@ if (dayCount >= THRESHOLD_HARD_SESSIONS) {
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.`);
// Burst — a run of read-only tools is bulk reading, not rapid-fire work
if (!burstReadOnly) {
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)
// 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 stuck/spiral claim, 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 (!level) level = 'soft';
messages.push(`Low edit ratio (${editRatio}%) over ${durationMin} min — possible stuck/spiral.`);
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 — possible stuck/spiral.`);
}
}
// Late night check