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
337 lines
9.6 KiB
JavaScript
337 lines
9.6 KiB
JavaScript
// Interaction Awareness — Shared library for Layer 2 hooks (Node.js)
|
||
// Imported by all hook scripts. Cross-platform: macOS, Linux, Windows.
|
||
// Zero npm dependencies — Node.js stdlib only.
|
||
|
||
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, unlinkSync } from 'fs';
|
||
import { join, isAbsolute } from 'path';
|
||
import { homedir } from 'os';
|
||
|
||
// --- Stdin ---
|
||
|
||
let _input = {};
|
||
|
||
export function readStdin() {
|
||
try {
|
||
const raw = readFileSync(0, 'utf8');
|
||
_input = JSON.parse(raw);
|
||
} catch {
|
||
_input = {};
|
||
}
|
||
}
|
||
|
||
export function getField(key) {
|
||
return _input[key] ?? '';
|
||
}
|
||
|
||
export function getSessionId() {
|
||
return getField('session_id');
|
||
}
|
||
|
||
export function getToolName() {
|
||
return getField('tool_name');
|
||
}
|
||
|
||
export function getInput() {
|
||
return _input;
|
||
}
|
||
|
||
// --- Paths ---
|
||
|
||
const PLUGIN_DATA = process.env.CLAUDE_PLUGIN_DATA
|
||
|| join(homedir(), '.claude', 'plugins', 'data', 'ai-psychosis');
|
||
|
||
export const DATA_DIR = PLUGIN_DATA;
|
||
export const SESSIONS_LOG = join(DATA_DIR, 'sessions.jsonl');
|
||
export const EVENTS_LOG = join(DATA_DIR, 'events.jsonl');
|
||
export const STATE_DIR = join(DATA_DIR, 'state');
|
||
|
||
// --- Layer configuration ---
|
||
|
||
let LAYER2_ENABLED = true;
|
||
let LAYER3_ENABLED = false;
|
||
let LAYER4_ENABLED = false;
|
||
|
||
export function initConfig() {
|
||
const cwd = getField('cwd');
|
||
|
||
// Project-level config takes precedence over global. cwd is a base
|
||
// directory, not a filename segment, so it isn't put through the
|
||
// session_id allowlist below — only rejected if it isn't a well-formed
|
||
// absolute path (defends against embedded NUL bytes; see F-5).
|
||
const candidates = [];
|
||
if (cwd && isAbsolute(cwd) && !cwd.includes('\0')) {
|
||
candidates.push(join(cwd, '.claude', 'ai-psychosis.local.md'));
|
||
}
|
||
candidates.push(join(homedir(), '.claude', 'ai-psychosis.local.md'));
|
||
|
||
let content;
|
||
for (const configFile of candidates) {
|
||
try {
|
||
content = readFileSync(configFile, 'utf8');
|
||
break;
|
||
} catch { /* try next */ }
|
||
}
|
||
if (!content) return;
|
||
|
||
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
||
if (!match) return;
|
||
|
||
const frontmatter = match[1];
|
||
for (const line of frontmatter.split('\n')) {
|
||
const m = line.match(/^(layer[234]):\s*(.+)/);
|
||
if (!m) continue;
|
||
const val = m[2].trim().replace(/^["']|["']$/g, '');
|
||
if (m[1] === 'layer2') LAYER2_ENABLED = val === 'true';
|
||
if (m[1] === 'layer3') LAYER3_ENABLED = val === 'true';
|
||
if (m[1] === 'layer4') LAYER4_ENABLED = val === 'true';
|
||
}
|
||
}
|
||
|
||
export function requireLayer(n) {
|
||
let enabled = false;
|
||
if (n === 2) enabled = LAYER2_ENABLED;
|
||
if (n === 3) enabled = LAYER3_ENABLED;
|
||
if (n === 4) enabled = LAYER4_ENABLED;
|
||
|
||
if (!enabled) {
|
||
outputContinue();
|
||
process.exit(0);
|
||
}
|
||
}
|
||
|
||
// --- Time helpers ---
|
||
|
||
export function nowIso() {
|
||
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||
}
|
||
|
||
export function nowEpoch() {
|
||
return Math.floor(Date.now() / 1000);
|
||
}
|
||
|
||
export function currentHour() {
|
||
return new Date().getHours();
|
||
}
|
||
|
||
export function isLateNight() {
|
||
const h = currentHour();
|
||
return h >= 23 || h < 5;
|
||
}
|
||
|
||
// --- Thresholds ---
|
||
|
||
export const THRESHOLD_SOFT_DURATION = 90;
|
||
export const THRESHOLD_HARD_DURATION = 180;
|
||
export const THRESHOLD_SOFT_SESSIONS = 6;
|
||
export const THRESHOLD_HARD_SESSIONS = 10;
|
||
export const THRESHOLD_SOFT_BURST = 5;
|
||
export const THRESHOLD_HARD_BURST = 10;
|
||
export const THRESHOLD_BURST_INTERVAL = 30;
|
||
export const THRESHOLD_LOW_EDIT_RATIO = 10;
|
||
export const THRESHOLD_LOW_EDIT_MIN_DURATION = 30;
|
||
export const THRESHOLD_SOFT_DEP_FLAGS = 2;
|
||
export const THRESHOLD_HARD_DEP_FLAGS = 5;
|
||
export const COOLDOWN_SOFT = 1800;
|
||
export const COOLDOWN_HARD = 3600;
|
||
|
||
// Read-only tools. A sequence of these is bulk reading (research, audit,
|
||
// bulk file review), not rapid-fire editing — the burst and edit-ratio
|
||
// heuristics must not treat the two as the same signal.
|
||
export const READ_TOOLS = Object.freeze(['Read', 'Grep', 'Glob', 'NotebookRead']);
|
||
export function isReadTool(tool) {
|
||
return READ_TOOLS.includes(tool);
|
||
}
|
||
// Share of read tools above which a session counts as read-dominant.
|
||
export const THRESHOLD_READ_DOMINANT_RATIO = 70;
|
||
// v1.1.0 — counting threshold; tier-reduction logic is v1.2 scope
|
||
export const THRESHOLD_PUSHBACK_FLAGS = 2;
|
||
|
||
// --- v1.2 thresholds and domain-stakes table ---
|
||
//
|
||
// Sources: Anthropic guidance paper Appendix (April 2026), Figure A1 (stakes),
|
||
// Figure A4 (domain pushback rates). All domain identifiers are SINGULAR to
|
||
// match v1.1.0's `state.domain_context = 'relationship'` convention.
|
||
|
||
export const TIER1_TURN_THRESHOLD = 15;
|
||
export const TIER2_SESSION_THRESHOLD = 3;
|
||
export const THRESHOLD_VALSEEK_FLAGS = 3;
|
||
|
||
// Domain stakes weights — Figure A1 high/very-high stakes domains carry
|
||
// higher multipliers; consumer/personal_dev are baseline 1.0.
|
||
export const DOMAIN_STAKES = Object.freeze({
|
||
legal: 1.5,
|
||
parenting: 1.5,
|
||
health: 1.5,
|
||
financial: 1.5,
|
||
relationship: 1.3,
|
||
spirituality: 1.2,
|
||
professional: 1.1,
|
||
wellbeing: 1.2,
|
||
lifepath: 1.1,
|
||
values: 1.2,
|
||
personal_dev: 1.0,
|
||
consumer: 1.0,
|
||
default: 1.0
|
||
});
|
||
|
||
// Pushback in these domains signals validation-pressing (Figure A4 — relationships
|
||
// 21%, spirituality 19%); pushback alert fires.
|
||
export const HIGH_SYCOPHANCY_DOMAINS = Object.freeze(['relationship', 'spirituality']);
|
||
|
||
// High-stakes guidance domains (Figure A1 high/very-high). Tier-1 user-info
|
||
// alert fires only when domain_context intersects this set.
|
||
export const HIGH_STAKES_DOMAINS = Object.freeze(['legal', 'parenting', 'health', 'financial']);
|
||
|
||
// Info-seeking domains where pushback signals healthy self-advocacy (Figure A4 —
|
||
// parenting 7.9%, legal/health/financial 80–94% pushback rate). Pushback alert
|
||
// is suppressed when domain_context is entirely within this set.
|
||
export const INFO_DOMAINS = Object.freeze(['legal', 'parenting', 'health', 'financial', 'professional']);
|
||
|
||
// --- Session counting ---
|
||
|
||
export function sessionsToday() {
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
if (!existsSync(SESSIONS_LOG)) return 0;
|
||
|
||
try {
|
||
const lines = readFileSync(SESSIONS_LOG, 'utf8').split('\n').filter(Boolean);
|
||
const ids = new Set();
|
||
for (const line of lines) {
|
||
try {
|
||
const rec = JSON.parse(line);
|
||
if (rec.start && rec.start.startsWith(today)) {
|
||
ids.add(rec.session_id);
|
||
}
|
||
} catch { /* skip malformed lines */ }
|
||
}
|
||
return ids.size;
|
||
} catch {
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
// Tail-first scan: return the N most recent end records (records with
|
||
// duration_min defined) in chronological order. Cost is bounded by N, not
|
||
// by total file size — a 50K-record sessions.jsonl is read once but only
|
||
// the last few hundred lines are JSON-parsed before N is satisfied.
|
||
export function readRecentEndRecords(n) {
|
||
if (!Number.isFinite(n) || n <= 0) return [];
|
||
if (!existsSync(SESSIONS_LOG)) return [];
|
||
|
||
let lines;
|
||
try {
|
||
lines = readFileSync(SESSIONS_LOG, 'utf8').split('\n');
|
||
} catch {
|
||
return [];
|
||
}
|
||
|
||
const collected = [];
|
||
for (let i = lines.length - 1; i >= 0 && collected.length < n; i--) {
|
||
const line = lines[i];
|
||
if (!line) continue;
|
||
try {
|
||
const rec = JSON.parse(line);
|
||
if (rec.duration_min !== undefined) {
|
||
collected.push(rec);
|
||
}
|
||
} catch { /* skip malformed */ }
|
||
}
|
||
|
||
// Reverse so caller receives oldest-first (chronological order).
|
||
return collected.reverse();
|
||
}
|
||
|
||
// --- State file management ---
|
||
|
||
// session_id becomes a raw filename segment, so an unvalidated value (e.g.
|
||
// containing "../") could escape STATE_DIR via path traversal. Harness-supplied,
|
||
// not user-controlled — this is defense-in-depth hardening (F-5), not a fix for
|
||
// an observed exploit. Values that fail the allowlist degrade to a fixed
|
||
// sentinel filename rather than blocking the hook.
|
||
const SAFE_ID_RE = /^[A-Za-z0-9_-]+$/;
|
||
|
||
export function sessionStateFile(sid) {
|
||
sid = sid || getSessionId();
|
||
if (!SAFE_ID_RE.test(sid)) sid = 'invalid-session-id';
|
||
return join(STATE_DIR, `${sid}.json`);
|
||
}
|
||
|
||
export function readState(sid) {
|
||
const sf = sessionStateFile(sid);
|
||
try {
|
||
return JSON.parse(readFileSync(sf, 'utf8'));
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
export function getStateField(key, sid) {
|
||
const state = readState(sid);
|
||
return state[key] ?? '';
|
||
}
|
||
|
||
export function getStateInt(key, sid) {
|
||
const state = readState(sid);
|
||
return Math.floor(Number(state[key]) || 0);
|
||
}
|
||
|
||
export function writeState(obj, sid) {
|
||
const sf = sessionStateFile(sid);
|
||
writeFileSync(sf, JSON.stringify(obj, null, 2) + '\n');
|
||
}
|
||
|
||
export function updateStateField(key, value, sid) {
|
||
const state = readState(sid);
|
||
state[key] = value;
|
||
writeState(state, sid);
|
||
}
|
||
|
||
export function incrementStateField(key, sid) {
|
||
const state = readState(sid);
|
||
state[key] = (Number(state[key]) || 0) + 1;
|
||
writeState(state, sid);
|
||
}
|
||
|
||
// --- Cooldown ---
|
||
|
||
export function checkCooldown(cooldownSecs, sid) {
|
||
const lastWarning = getStateInt('last_warning_epoch', sid);
|
||
const now = nowEpoch();
|
||
return (now - lastWarning) >= cooldownSecs;
|
||
}
|
||
|
||
export function recordWarning(sid) {
|
||
const state = readState(sid);
|
||
state.last_warning_epoch = nowEpoch();
|
||
writeState(state, sid);
|
||
}
|
||
|
||
// --- Output helpers ---
|
||
|
||
export function outputContinue() {
|
||
process.stdout.write(JSON.stringify({ continue: true }) + '\n');
|
||
}
|
||
|
||
export function outputWithContext(message, hookEventName) {
|
||
process.stdout.write(JSON.stringify({
|
||
continue: true,
|
||
hookSpecificOutput: {
|
||
hookEventName,
|
||
additionalContext: message
|
||
}
|
||
}) + '\n');
|
||
}
|
||
|
||
// --- File helpers ---
|
||
|
||
export function ensureDir(dir) {
|
||
mkdirSync(dir, { recursive: true });
|
||
}
|
||
|
||
export function appendJsonl(file, obj) {
|
||
appendFileSync(file, JSON.stringify(obj) + '\n');
|
||
}
|
||
|
||
export function removeFile(file) {
|
||
try { unlinkSync(file); } catch { /* ignore if missing */ }
|
||
}
|