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:
parent
626140bb6a
commit
2c9e2de00a
5 changed files with 141 additions and 13 deletions
|
|
@ -93,3 +93,75 @@ describe('tool-tracker', () => {
|
|||
assert.equal(out.continue, true);
|
||||
});
|
||||
});
|
||||
|
||||
// Tiltak 1 (BRIEF-vurdering-v2.md): burst and edit-ratio heuristics must
|
||||
// differentiate on tool type. A bulk-read sequence is structurally
|
||||
// indistinguishable from a rapid-fire editing sequence today.
|
||||
describe('tool-tracker — task-type calibration', () => {
|
||||
it('does not raise a rapid-fire alert for a pure read burst', () => {
|
||||
dir = setupTestDir();
|
||||
// burst_count 9 + this call = THRESHOLD_HARD_BURST (10), all reads
|
||||
createStateFile(dir, 'b1', freshState({
|
||||
last_event_epoch: Math.floor(Date.now() / 1000) - 5,
|
||||
burst_count: 9,
|
||||
tool_count: 30,
|
||||
read_count: 30,
|
||||
}));
|
||||
const out = runHook('tool-tracker.mjs', { session_id: 'b1', tool_name: 'Read' }, dir);
|
||||
const ctx = out.hookSpecificOutput?.additionalContext || '';
|
||||
assert.ok(!ctx.includes('Rapid-fire'), `expected no rapid-fire alert, got: ${ctx}`);
|
||||
assert.equal(out.continue, true);
|
||||
});
|
||||
|
||||
it('still raises a rapid-fire alert when the burst includes an edit', () => {
|
||||
dir = setupTestDir();
|
||||
createStateFile(dir, 'b2', freshState({
|
||||
last_event_epoch: Math.floor(Date.now() / 1000) - 5,
|
||||
burst_count: 9,
|
||||
tool_count: 30,
|
||||
read_count: 29,
|
||||
}));
|
||||
const out = runHook('tool-tracker.mjs', { session_id: 'b2', tool_name: 'Edit' }, dir);
|
||||
const ctx = out.hookSpecificOutput?.additionalContext || '';
|
||||
assert.ok(ctx.includes('Rapid-fire'), `expected rapid-fire alert, got: ${ctx}`);
|
||||
});
|
||||
|
||||
it('does not call a read-dominant session stuck/spiral', () => {
|
||||
dir = setupTestDir();
|
||||
// 40 min, 50 tool calls, no edits, all reads — a research/audit session
|
||||
createStateFile(dir, 'e1', freshState({
|
||||
start_epoch: Math.floor(Date.now() / 1000) - 40 * 60,
|
||||
tool_count: 49,
|
||||
edit_count: 0,
|
||||
read_count: 49,
|
||||
}));
|
||||
const out = runHook('tool-tracker.mjs', { session_id: 'e1', tool_name: 'Read' }, dir);
|
||||
const ctx = out.hookSpecificOutput?.additionalContext || '';
|
||||
assert.ok(!ctx.includes('stuck/spiral'), `expected no stuck/spiral claim, got: ${ctx}`);
|
||||
});
|
||||
|
||||
it('still reports low edit ratio when the session is not read-dominant', () => {
|
||||
dir = setupTestDir();
|
||||
// Same duration and volume, but reads are a minority (5/50)
|
||||
createStateFile(dir, 'e2', freshState({
|
||||
start_epoch: Math.floor(Date.now() / 1000) - 40 * 60,
|
||||
tool_count: 49,
|
||||
edit_count: 2,
|
||||
read_count: 5,
|
||||
}));
|
||||
const out = runHook('tool-tracker.mjs', { session_id: 'e2', tool_name: 'Bash' }, dir);
|
||||
const ctx = out.hookSpecificOutput?.additionalContext || '';
|
||||
assert.ok(ctx.includes('stuck/spiral'), `expected stuck/spiral claim, got: ${ctx}`);
|
||||
});
|
||||
|
||||
it('counts read tools in read_count and leaves it alone for others', () => {
|
||||
dir = setupTestDir();
|
||||
createStateFile(dir, 'r1', freshState());
|
||||
runHook('tool-tracker.mjs', { session_id: 'r1', tool_name: 'Grep' }, dir);
|
||||
assert.equal(readState(dir, 'r1').read_count, 1);
|
||||
runHook('tool-tracker.mjs', { session_id: 'r1', tool_name: 'Glob' }, dir);
|
||||
assert.equal(readState(dir, 'r1').read_count, 2);
|
||||
runHook('tool-tracker.mjs', { session_id: 'r1', tool_name: 'Write' }, dir);
|
||||
assert.equal(readState(dir, 'r1').read_count, 2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue