import { describe, it, afterEach } from 'node:test'; import assert from 'node:assert/strict'; import { join } from 'path'; import { runHook, setupTestDir, cleanupTestDir, createStateFile, readState, readJsonl } from './test-helper.mjs'; let dir; function freshState(overrides = {}) { return { start_epoch: Math.floor(Date.now() / 1000) - 60, start_iso: '2026-01-01T10:00:00Z', tool_count: 0, edit_count: 0, last_event_epoch: 0, burst_count: 0, dep_flags: 0, esc_flags: 0, fatigue_flags: 0, val_flags: 0, last_warning_epoch: 0, ...overrides, }; } afterEach(() => { if (dir) cleanupTestDir(dir); }); describe('tool-tracker', () => { it('tracks tool call and increments tool_count', () => { dir = setupTestDir(); createStateFile(dir, 't1', freshState()); runHook('tool-tracker.mjs', { session_id: 't1', tool_name: 'Read' }, dir); const s = readState(dir, 't1'); assert.equal(s.tool_count, 1); const events = readJsonl(join(dir, 'events.jsonl')); assert.equal(events.length, 1); assert.equal(events[0].tool_name, 'Read'); assert.equal(events[0].session_id, 't1'); }); it('increments edit_count for Edit tool', () => { dir = setupTestDir(); createStateFile(dir, 't2', freshState()); runHook('tool-tracker.mjs', { session_id: 't2', tool_name: 'Edit' }, dir); const s = readState(dir, 't2'); assert.equal(s.edit_count, 1); }); it('does not increment edit_count for non-Edit tool', () => { dir = setupTestDir(); createStateFile(dir, 't3', freshState()); runHook('tool-tracker.mjs', { session_id: 't3', tool_name: 'Bash' }, dir); const s = readState(dir, 't3'); assert.equal(s.edit_count, 0); }); it('detects burst when interval < 30s', () => { dir = setupTestDir(); createStateFile(dir, 't4', freshState({ last_event_epoch: Math.floor(Date.now() / 1000) - 5, burst_count: 0, })); runHook('tool-tracker.mjs', { session_id: 't4', tool_name: 'Read' }, dir); const s = readState(dir, 't4'); assert.equal(s.burst_count, 1); }); it('resets burst when interval >= 30s', () => { dir = setupTestDir(); createStateFile(dir, 't5', freshState({ last_event_epoch: Math.floor(Date.now() / 1000) - 60, burst_count: 3, })); runHook('tool-tracker.mjs', { session_id: 't5', tool_name: 'Read' }, dir); const s = readState(dir, 't5'); assert.equal(s.burst_count, 0); }); it('emits periodic reminder at modulo 25', () => { dir = setupTestDir(); createStateFile(dir, 't6', freshState({ tool_count: 24 })); const out = runHook('tool-tracker.mjs', { session_id: 't6', tool_name: 'Read' }, dir); assert.equal(out.hookSpecificOutput?.hookEventName, 'PostToolUse'); assert.ok(out.hookSpecificOutput?.additionalContext?.includes('REMINDER')); }); it('outputs continue between checkpoints', () => { dir = setupTestDir(); createStateFile(dir, 't7', freshState({ tool_count: 5 })); const out = runHook('tool-tracker.mjs', { session_id: 't7', tool_name: 'Read' }, dir); assert.equal(out.continue, true); assert.ok(!out.hookSpecificOutput); }); it('handles missing state file gracefully', () => { dir = setupTestDir(); // No state file created const out = runHook('tool-tracker.mjs', { session_id: 'missing', tool_name: 'Read' }, dir); 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); }); });