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
250 lines
10 KiB
JavaScript
250 lines
10 KiB
JavaScript
import { describe, it, afterEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'fs';
|
|
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('Fast tool calls'), `expected no burst 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('Fast tool calls'), `expected burst 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('Low edit ratio'), `expected low-edit-ratio observation, 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);
|
|
});
|
|
});
|
|
|
|
// Tiltak 2 (BRIEF-vurdering-v2.md): alert wording must read as an invitation to
|
|
// investigate, not as a verdict to approve or reject. Every alert states what
|
|
// was observed and what would distinguish a benign reading from a concerning
|
|
// one — the form the read-dominant edit-ratio message already uses.
|
|
describe('tool-tracker — investigative alert wording', () => {
|
|
it('states what to check instead of labelling the burst', () => {
|
|
dir = setupTestDir();
|
|
createStateFile(dir, 'w1', 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: 'w1', tool_name: 'Edit' }, dir);
|
|
const ctx = out.hookSpecificOutput?.additionalContext || '';
|
|
assert.ok(!ctx.includes('Rapid-fire'), `burst alert still carries a verdict label: ${ctx}`);
|
|
assert.ok(ctx.includes('check whether each change was verified'),
|
|
`burst alert names no check: ${ctx}`);
|
|
});
|
|
|
|
it('offers the counter-signal and a check instead of a stuck/spiral diagnosis', () => {
|
|
dir = setupTestDir();
|
|
createStateFile(dir, 'w2', 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: 'w2', tool_name: 'Bash' }, dir);
|
|
const ctx = out.hookSpecificOutput?.additionalContext || '';
|
|
assert.ok(!ctx.includes('stuck/spiral'), `edit-ratio alert still diagnoses: ${ctx}`);
|
|
assert.ok(ctx.includes('% of tool calls are reads'),
|
|
`edit-ratio alert omits the counter-signal: ${ctx}`);
|
|
assert.ok(ctx.includes('check what the other calls are doing'),
|
|
`edit-ratio alert names no check: ${ctx}`);
|
|
});
|
|
|
|
it('closes a soft warning with framing, not a prescription', () => {
|
|
dir = setupTestDir();
|
|
// 95 min (soft duration), edit ratio 20% — isolates the closing sentence
|
|
createStateFile(dir, 'w3', freshState({
|
|
start_epoch: Math.floor(Date.now() / 1000) - 95 * 60,
|
|
tool_count: 24,
|
|
edit_count: 5,
|
|
read_count: 10,
|
|
}));
|
|
const out = runHook('tool-tracker.mjs', { session_id: 'w3', tool_name: 'Bash' }, dir);
|
|
const ctx = out.hookSpecificOutput?.additionalContext || '';
|
|
assert.ok(ctx.includes('Session: 95 min'), `soft warning did not fire: ${ctx}`);
|
|
assert.ok(!ctx.includes('Consider a break'), `soft warning still prescribes: ${ctx}`);
|
|
assert.ok(ctx.includes('observations, not conclusions'),
|
|
`soft warning omits the framing: ${ctx}`);
|
|
});
|
|
|
|
it('asks the hard warning to name its observations before suggesting stopping', () => {
|
|
dir = setupTestDir();
|
|
// 190 min — hard duration threshold
|
|
createStateFile(dir, 'w4', freshState({
|
|
start_epoch: Math.floor(Date.now() / 1000) - 190 * 60,
|
|
tool_count: 24,
|
|
edit_count: 5,
|
|
read_count: 10,
|
|
}));
|
|
const out = runHook('tool-tracker.mjs', { session_id: 'w4', tool_name: 'Bash' }, dir);
|
|
const ctx = out.hookSpecificOutput?.additionalContext || '';
|
|
assert.ok(ctx.includes('INTERACTION AWARENESS'), `hard warning did not fire: ${ctx}`);
|
|
assert.ok(ctx.includes('Name these observations to the user and ask what they reflect'),
|
|
`hard warning omits the investigative framing: ${ctx}`);
|
|
// The required action is unchanged — this is a wording change, not a behaviour change.
|
|
assert.ok(ctx.includes('require you to suggest stopping'),
|
|
`hard warning dropped the required action: ${ctx}`);
|
|
});
|
|
|
|
it('leaves no verdict labels in the hook source', () => {
|
|
const src = readFileSync(
|
|
join(import.meta.dirname, '..', 'hooks', 'scripts', 'tool-tracker.mjs'), 'utf8');
|
|
for (const label of ['Rapid-fire', 'stuck/spiral', 'Consider a break']) {
|
|
assert.ok(!src.includes(label), `tool-tracker.mjs still contains verdict label: ${label}`);
|
|
}
|
|
});
|
|
});
|