feat: add ai-psychosis plugin to open marketplace

Meta-awareness tools for healthy AI interaction patterns.
Detects reinforcement loops, scope escalation, and compulsive patterns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-04-06 20:46:09 +02:00
commit 297867f847
19 changed files with 1920 additions and 0 deletions

View file

@ -0,0 +1,44 @@
import { describe, it, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'fs';
import { join } from 'path';
import { runHook, setupTestDir, cleanupTestDir } from './test-helper.mjs';
let dir;
afterEach(() => { if (dir) cleanupTestDir(dir); });
function readAllFiles(dirPath) {
let content = '';
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
const full = join(dirPath, entry.name);
if (entry.isDirectory()) {
content += readAllFiles(full);
} else {
content += readFileSync(full, 'utf8');
}
}
return content;
}
describe('privacy', () => {
it('never writes prompt text to disk through full lifecycle', () => {
dir = setupTestDir();
const canary = 'CANARY_PRIVACY_xyz123';
// 1. Session start
runHook('session-start.mjs', { session_id: 'priv1', cwd: '/tmp' }, dir);
// 2. Prompt analysis with canary as prompt text
runHook('prompt-analyzer.mjs', { session_id: 'priv1', prompt: `tell me what to do ${canary} am I right?` }, dir);
// 3. Tool tracking
runHook('tool-tracker.mjs', { session_id: 'priv1', tool_name: 'Edit' }, dir);
// 4. Session end
runHook('session-end.mjs', { session_id: 'priv1', cwd: '/tmp' }, dir);
// Read ALL files recursively — canary must not appear anywhere
const allContent = readAllFiles(dir);
assert.ok(!allContent.includes(canary), `Canary "${canary}" found in data files — privacy violation`);
});
});

View file

@ -0,0 +1,313 @@
import { describe, it, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { runHook, setupTestDir, cleanupTestDir, createStateFile, readState } from './test-helper.mjs';
let dir;
function freshState() {
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,
};
}
function runPrompt(prompt, stateOverrides = {}) {
dir = setupTestDir();
createStateFile(dir, 'p1', { ...freshState(), ...stateOverrides });
runHook('prompt-analyzer.mjs', { session_id: 'p1', prompt }, dir);
return readState(dir, 'p1');
}
afterEach(() => { if (dir) cleanupTestDir(dir); });
// --- Dependency patterns (7 positive, 7 negative) ---
describe('dependency patterns', () => {
it('matches "tell me what to do"', () => {
const s = runPrompt('please tell me what to do');
assert.equal(s.dep_flags, 1);
});
it('does not match "I told him the plan"', () => {
const s = runPrompt('I told him the plan');
assert.equal(s.dep_flags, 0);
});
it('matches "what should I do"', () => {
const s = runPrompt('what should I do next?');
assert.equal(s.dep_flags, 1);
});
it('does not match "I know what to build"', () => {
const s = runPrompt('I know what to build');
assert.equal(s.dep_flags, 0);
});
it('matches "am I right"', () => {
const s = runPrompt('am I right about this?');
assert.equal(s.dep_flags, 1);
});
it('does not match "I turned the server right"', () => {
const s = runPrompt('I turned the server on');
assert.equal(s.dep_flags, 0);
});
it('matches "you understand me"', () => {
const s = runPrompt('you understand me well');
assert.equal(s.dep_flags, 1);
});
it('does not match "do you understand merging?"', () => {
const s = runPrompt('do you understand merging?');
assert.equal(s.dep_flags, 0);
});
it('matches "you\'re the only"', () => {
const s = runPrompt("you're the only one who gets it");
assert.equal(s.dep_flags, 1);
});
it('does not match "the only option is refactoring"', () => {
const s = runPrompt('the only option is refactoring');
assert.equal(s.dep_flags, 0);
});
it('matches "can I do this"', () => {
const s = runPrompt('can I do this alone?');
assert.equal(s.dep_flags, 1);
});
it('does not match "we can implement this later"', () => {
const s = runPrompt('we can implement this later');
assert.equal(s.dep_flags, 0);
});
it('matches "I need you to decide"', () => {
const s = runPrompt('I need you to decide for me');
assert.equal(s.dep_flags, 1);
});
it('does not match "we need to deploy soon"', () => {
const s = runPrompt('we need to deploy soon');
assert.equal(s.dep_flags, 0);
});
});
// --- Escalation patterns (6 positive, 6 negative) ---
describe('escalation patterns', () => {
it('matches "definitely" as word', () => {
const s = runPrompt('this is definitely wrong');
assert.equal(s.esc_flags, 1);
});
it('does not match "definitively"', () => {
const s = runPrompt('this is definitively proven');
assert.equal(s.esc_flags, 0);
});
it('matches "clearly" as word', () => {
const s = runPrompt('clearly this is the issue');
assert.equal(s.esc_flags, 1);
});
it('does not match "nuclear unclear"', () => {
const s = runPrompt('nuclear unclear situation');
assert.equal(s.esc_flags, 0);
});
it('matches "this proves"', () => {
const s = runPrompt('this proves my point');
assert.equal(s.esc_flags, 1);
});
it('does not match "prove this theorem"', () => {
const s = runPrompt('prove this theorem');
assert.equal(s.esc_flags, 0);
});
it('matches "obviously" as word', () => {
const s = runPrompt('obviously we should refactor');
assert.equal(s.esc_flags, 1);
});
it('does not match "not an obvious choice"', () => {
const s = runPrompt('not an obvious choice');
assert.equal(s.esc_flags, 0);
});
it('matches "without a doubt"', () => {
const s = runPrompt('without a doubt this works');
assert.equal(s.esc_flags, 1);
});
it('does not match "I have some doubt"', () => {
const s = runPrompt('I have some doubt about it');
assert.equal(s.esc_flags, 0);
});
it('matches "this confirms"', () => {
const s = runPrompt('this confirms the theory');
assert.equal(s.esc_flags, 1);
});
it('does not match "please confirm the deploy"', () => {
const s = runPrompt('please confirm the deploy');
assert.equal(s.esc_flags, 0);
});
});
// --- Fatigue patterns (7 positive, 7 negative) ---
describe('fatigue patterns', () => {
it('matches "tired"', () => {
const s = runPrompt("I'm tired of debugging");
assert.equal(s.fatigue_flags, 1);
});
it('does not match "retired"', () => {
const s = runPrompt('I retired last year');
assert.equal(s.fatigue_flags, 0);
});
it('matches "exhausted"', () => {
const s = runPrompt("I'm exhausted.");
assert.equal(s.fatigue_flags, 1);
});
it('does not match "exhaustive"', () => {
const s = runPrompt('the options were exhaustive');
assert.equal(s.fatigue_flags, 0);
});
it('matches "can\'t think"', () => {
const s = runPrompt("I can't think straight");
assert.equal(s.fatigue_flags, 1);
});
it('does not match "I can think clearly"', () => {
const s = runPrompt('I can think of a solution');
assert.equal(s.fatigue_flags, 0);
});
it('matches "been at this"', () => {
const s = runPrompt("I've been at this all day");
assert.equal(s.fatigue_flags, 1);
});
it('does not match "haven\'t been at home"', () => {
const s = runPrompt("I haven't been at home");
assert.equal(s.fatigue_flags, 0);
});
it('matches "it\'s late"', () => {
const s = runPrompt("it's late, wrapping up");
assert.equal(s.fatigue_flags, 1);
});
it('does not match "the latest version"', () => {
const s = runPrompt('the latest version is good');
assert.equal(s.fatigue_flags, 0);
});
it('matches "should sleep"', () => {
const s = runPrompt('I should sleep');
assert.equal(s.fatigue_flags, 1);
});
it('does not match "sleep mode is enabled"', () => {
const s = runPrompt('enable sleep mode');
assert.equal(s.fatigue_flags, 0);
});
it('matches "hours now"', () => {
const s = runPrompt('been going for hours now');
assert.equal(s.fatigue_flags, 1);
});
it('does not match "hourly updates"', () => {
const s = runPrompt('hourly updates are fine');
assert.equal(s.fatigue_flags, 0);
});
});
// --- Validation patterns (5 positive, 5 negative) ---
describe('validation patterns', () => {
it('matches "right?"', () => {
const s = runPrompt('this works, right?');
assert.equal(s.val_flags, 1);
});
it('does not match "turn right"', () => {
const s = runPrompt('turn right at the fork');
assert.equal(s.val_flags, 0);
});
it('matches "don\'t you think"', () => {
const s = runPrompt("don't you think so?");
assert.equal(s.val_flags, 1);
});
it('does not match "I don\'t think so"', () => {
const s = runPrompt("I don't think so");
assert.equal(s.val_flags, 0);
});
it('matches "you agree"', () => {
const s = runPrompt('you agree with me');
assert.equal(s.val_flags, 1);
});
it('does not match "if parties agree"', () => {
const s = runPrompt('if parties agree on terms');
assert.equal(s.val_flags, 0);
});
it('matches "correct?"', () => {
const s = runPrompt('is this correct?');
assert.equal(s.val_flags, 1);
});
it('does not match "correct the typo"', () => {
const s = runPrompt("I'll correct the typo");
assert.equal(s.val_flags, 0);
});
it('matches "isn\'t it"', () => {
const s = runPrompt('good approach, isn\'t it');
assert.equal(s.val_flags, 1);
});
it('does not match "it isn\'t working"', () => {
const s = runPrompt("it isn't working yet");
assert.equal(s.val_flags, 0);
});
});
// --- Threshold and cooldown tests (6 cases) ---
describe('thresholds and cooldowns', () => {
it('warns at dependency soft threshold (2 flags)', () => {
dir = setupTestDir();
createStateFile(dir, 'p1', { ...freshState(), dep_flags: 1 });
const out = runHook('prompt-analyzer.mjs', { session_id: 'p1', prompt: 'tell me what to do' }, dir);
assert.ok(out.hookSpecificOutput?.additionalContext?.includes('Dependency language noticed'));
});
it('warns hard at dependency threshold (5 flags)', () => {
dir = setupTestDir();
createStateFile(dir, 'p1', { ...freshState(), dep_flags: 4 });
const out = runHook('prompt-analyzer.mjs', { session_id: 'p1', prompt: 'tell me what to do' }, dir);
assert.ok(out.hookSpecificOutput?.additionalContext?.includes('INTERACTION AWARENESS'));
});
it('fatigue bypasses cooldown', () => {
dir = setupTestDir();
createStateFile(dir, 'p1', { ...freshState(), last_warning_epoch: Math.floor(Date.now() / 1000) });
const out = runHook('prompt-analyzer.mjs', { session_id: 'p1', prompt: "I'm tired" }, dir);
assert.ok(out.hookSpecificOutput?.additionalContext?.includes('Fatigue language detected'));
});
it('cooldown suppresses non-fatigue warning', () => {
dir = setupTestDir();
createStateFile(dir, 'p1', { ...freshState(), dep_flags: 4, last_warning_epoch: Math.floor(Date.now() / 1000) });
const out = runHook('prompt-analyzer.mjs', { session_id: 'p1', prompt: 'tell me what to do' }, dir);
assert.equal(out.continue, true);
assert.ok(!out.hookSpecificOutput);
});
it('warns at escalation threshold (3 flags)', () => {
dir = setupTestDir();
createStateFile(dir, 'p1', { ...freshState(), esc_flags: 2 });
const out = runHook('prompt-analyzer.mjs', { session_id: 'p1', prompt: 'this is definitely the issue' }, dir);
assert.ok(out.hookSpecificOutput?.additionalContext?.includes('Escalation language detected'));
});
it('warns at validation threshold (3 flags)', () => {
dir = setupTestDir();
createStateFile(dir, 'p1', { ...freshState(), val_flags: 2 });
const out = runHook('prompt-analyzer.mjs', { session_id: 'p1', prompt: 'this is correct, right?' }, dir);
assert.ok(out.hookSpecificOutput?.additionalContext?.includes('Validation-seeking pattern'));
});
});

View file

@ -0,0 +1,66 @@
import { describe, it, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'path';
import { existsSync } from 'fs';
import { runHook, setupTestDir, cleanupTestDir, createStateFile, readJsonl } from './test-helper.mjs';
let dir;
afterEach(() => { if (dir) cleanupTestDir(dir); });
describe('session-end', () => {
it('finalizes session record and deletes state file', () => {
dir = setupTestDir();
const nowEpoch = Math.floor(Date.now() / 1000);
createStateFile(dir, 's1', {
start_epoch: nowEpoch - 300, start_iso: '2026-01-01T10:00:00Z',
tool_count: 5, edit_count: 2,
dep_flags: 1, esc_flags: 0, fatigue_flags: 0, val_flags: 1,
last_event_epoch: 0, burst_count: 0, last_warning_epoch: 0,
});
runHook('session-end.mjs', { session_id: 's1', cwd: '/tmp' }, dir);
const records = readJsonl(join(dir, 'sessions.jsonl'));
const end = records.find(r => r.end);
assert.ok(end);
assert.equal(end.session_id, 's1');
assert.equal(end.tool_count, 5);
assert.equal(end.edit_count, 2);
assert.ok(!existsSync(join(dir, 'state', 's1.json')));
});
it('computes duration correctly', () => {
dir = setupTestDir();
const nowEpoch = Math.floor(Date.now() / 1000);
createStateFile(dir, 's2', {
start_epoch: nowEpoch - 3600, start_iso: '2026-01-01T10:00:00Z',
tool_count: 10, edit_count: 3,
dep_flags: 0, esc_flags: 0, fatigue_flags: 0, val_flags: 0,
last_event_epoch: 0, burst_count: 0, last_warning_epoch: 0,
});
runHook('session-end.mjs', { session_id: 's2', cwd: '/tmp' }, dir);
const records = readJsonl(join(dir, 'sessions.jsonl'));
const end = records.find(r => r.end);
assert.ok(end.duration_min >= 59 && end.duration_min <= 61);
});
it('preserves flags in final record', () => {
dir = setupTestDir();
createStateFile(dir, 's3', {
start_epoch: Math.floor(Date.now() / 1000) - 60, start_iso: '2026-01-01T10:00:00Z',
tool_count: 1, edit_count: 0,
dep_flags: 3, esc_flags: 1, fatigue_flags: 2, val_flags: 0,
last_event_epoch: 0, burst_count: 0, last_warning_epoch: 0,
});
runHook('session-end.mjs', { session_id: 's3', cwd: '/tmp' }, dir);
const records = readJsonl(join(dir, 'sessions.jsonl'));
const end = records.find(r => r.end);
assert.deepEqual(end.flags, { dependency: 3, escalation: 1, fatigue: 2, validation: 0 });
});
it('handles missing state file gracefully', () => {
dir = setupTestDir();
runHook('session-end.mjs', { session_id: 'missing', cwd: '/tmp' }, dir);
const records = readJsonl(join(dir, 'sessions.jsonl'));
assert.equal(records.length, 1);
assert.equal(records[0].note, 'no_state_file');
});
});

View file

@ -0,0 +1,49 @@
import { describe, it, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'path';
import { runHook, setupTestDir, cleanupTestDir, readState, readJsonl } from './test-helper.mjs';
let dir;
afterEach(() => { if (dir) cleanupTestDir(dir); });
describe('session-start', () => {
it('creates state file and emits context', () => {
dir = setupTestDir();
const out = runHook('session-start.mjs', { session_id: 's1', cwd: '/tmp' }, dir);
assert.equal(out.continue, true);
assert.ok(out.hookSpecificOutput.additionalContext.includes('Interaction Awareness is active'));
const state = readState(dir, 's1');
assert.ok(state);
assert.equal(state.tool_count, 0);
assert.equal(state.edit_count, 0);
assert.equal(state.dep_flags, 0);
});
it('writes start record to sessions.jsonl', () => {
dir = setupTestDir();
runHook('session-start.mjs', { session_id: 's2', cwd: '/tmp' }, dir);
const records = readJsonl(join(dir, 'sessions.jsonl'));
assert.equal(records.length, 1);
assert.equal(records[0].session_id, 's2');
assert.ok('hour' in records[0]);
assert.ok('is_late_night' in records[0]);
});
it('state has correct initial fields', () => {
dir = setupTestDir();
runHook('session-start.mjs', { session_id: 's3', cwd: '/tmp' }, dir);
const state = readState(dir, 's3');
assert.equal(state.burst_count, 0);
assert.equal(state.last_event_epoch, 0);
assert.equal(state.last_warning_epoch, 0);
assert.ok(state.start_epoch > 0);
assert.ok(state.start_iso.length > 0);
});
it('returns continue with no side effects when session_id missing', () => {
dir = setupTestDir();
const out = runHook('session-start.mjs', { cwd: '/tmp' }, dir);
assert.equal(out.continue, true);
assert.ok(!out.hookSpecificOutput);
});
});

View file

@ -0,0 +1,53 @@
// Shared test utilities for hook script tests.
// Uses node:child_process to pipe JSON stdin to hook scripts.
import { execSync } from 'child_process';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
const SCRIPTS_DIR = join(import.meta.dirname, '..', 'hooks', 'scripts');
export function runHook(scriptName, stdinJson, dataDir) {
const input = typeof stdinJson === 'string' ? stdinJson : JSON.stringify(stdinJson);
const env = { ...process.env, CLAUDE_PLUGIN_DATA: dataDir };
const stdout = execSync(`node ${join(SCRIPTS_DIR, scriptName)}`, {
input,
env,
encoding: 'utf8',
timeout: 5000,
});
try {
return JSON.parse(stdout.trim());
} catch {
return { raw: stdout.trim() };
}
}
export function setupTestDir() {
const dir = mkdtempSync(join(tmpdir(), 'ia-test-'));
mkdirSync(join(dir, 'state'), { recursive: true });
return dir;
}
export function cleanupTestDir(dir) {
rmSync(dir, { recursive: true, force: true });
}
export function createStateFile(dir, sid, state) {
writeFileSync(join(dir, 'state', `${sid}.json`), JSON.stringify(state, null, 2));
}
export function readState(dir, sid) {
const f = join(dir, 'state', `${sid}.json`);
if (!existsSync(f)) return null;
return JSON.parse(readFileSync(f, 'utf8'));
}
export function readJsonl(filePath) {
if (!existsSync(filePath)) return [];
return readFileSync(filePath, 'utf8')
.split('\n')
.filter(Boolean)
.map(line => JSON.parse(line));
}

View file

@ -0,0 +1,94 @@
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.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);
});
});