From 61584f42d63aa66b460ac28c11db771986df39f9 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 1 May 2026 21:40:24 +0200 Subject: [PATCH] feat(ai-psychosis): tier-2 user-info isolation alert (cross-session) --- .../hooks/scripts/session-end.mjs | 8 ++ .../hooks/scripts/session-start.mjs | 18 +++++ .../ai-psychosis/tests/session-start.test.mjs | 79 +++++++++++++++++++ 3 files changed, 105 insertions(+) diff --git a/plugins/ai-psychosis/hooks/scripts/session-end.mjs b/plugins/ai-psychosis/hooks/scripts/session-end.mjs index cbba605..f265b6e 100644 --- a/plugins/ai-psychosis/hooks/scripts/session-end.mjs +++ b/plugins/ai-psychosis/hooks/scripts/session-end.mjs @@ -52,6 +52,11 @@ if (startEpoch > 0) { durationMin = Math.floor((nowTs - startEpoch) / 60); } +// v1.2: also persist user_info_class (read-only — set during prompt-analyzer). +const userInfoClass = state.user_info_class || null; +const valseekCount = Number(state.valseek_count) || 0; +const turnCount = Number(state.turn_count) || 0; + // Append finalized session record appendJsonl(SESSIONS_LOG, { session_id: sid, @@ -61,6 +66,9 @@ appendJsonl(SESSIONS_LOG, { tool_count: toolCount, edit_count: editCount, domain_context: domainContextArray, + user_info_class: userInfoClass, + valseek_count: valseekCount, + turn_count: turnCount, flags: { dependency: depFlags, escalation: escFlags, diff --git a/plugins/ai-psychosis/hooks/scripts/session-start.mjs b/plugins/ai-psychosis/hooks/scripts/session-start.mjs index 8be6419..99f4335 100644 --- a/plugins/ai-psychosis/hooks/scripts/session-start.mjs +++ b/plugins/ai-psychosis/hooks/scripts/session-start.mjs @@ -5,7 +5,9 @@ import { readStdin, initConfig, requireLayer, getSessionId, nowEpoch, nowIso, currentHour, isLateNight, STATE_DIR, SESSIONS_LOG, THRESHOLD_SOFT_SESSIONS, + TIER2_SESSION_THRESHOLD, HIGH_STAKES_DOMAINS, ensureDir, appendJsonl, writeState, sessionsToday, + readRecentEndRecords, checkCooldown, outputWithContext } from './lib.mjs'; @@ -75,4 +77,20 @@ if (dayCount > THRESHOLD_SOFT_SESSIONS) { msg += ` This is your ${dayCount}th session today. Consider whether you need a longer break.`; } +// v1.2: Tier-2 cross-session isolation alert. +// Fires when the last N completed sessions all classify user as 'no' (no human +// contact) AND each one had at least one HIGH_STAKES_DOMAINS hit. This signals +// a sustained pattern across sessions, not just one-off context. +const recent = readRecentEndRecords(TIER2_SESSION_THRESHOLD); +if (recent.length >= TIER2_SESSION_THRESHOLD) { + const allNo = recent.every(r => r.user_info_class === 'no'); + const allHighStakes = recent.every(r => { + const ds = Array.isArray(r.domain_context) ? r.domain_context : (r.domain_context ? [r.domain_context] : []); + return ds.some(d => HIGH_STAKES_DOMAINS.includes(d)); + }); + if (allNo && allHighStakes) { + msg += ` INTERACTION AWARENESS (tier-2 cross-session isolation): ${recent.length} consecutive sessions show no human contact in high-stakes domains. This is a sustained pattern. Recommend a human check-in (trusted person, professional, or domain specialist) before proceeding here.`; + } +} + outputWithContext(msg); diff --git a/plugins/ai-psychosis/tests/session-start.test.mjs b/plugins/ai-psychosis/tests/session-start.test.mjs index 8e74b7f..a4efa57 100644 --- a/plugins/ai-psychosis/tests/session-start.test.mjs +++ b/plugins/ai-psychosis/tests/session-start.test.mjs @@ -1,6 +1,7 @@ import { describe, it, afterEach } from 'node:test'; import assert from 'node:assert/strict'; import { join } from 'path'; +import { writeFileSync } from 'fs'; import { runHook, setupTestDir, cleanupTestDir, readState, readJsonl } from './test-helper.mjs'; let dir; @@ -55,4 +56,82 @@ describe('session-start', () => { assert.equal(state.pushback_count, 0); assert.equal(state.domain_context, null); }); + + it('initializes v1.2 user-info, valseek, turn_count fields', () => { + dir = setupTestDir(); + runHook('session-start.mjs', { session_id: 's4b', cwd: '/tmp' }, dir); + const state = readState(dir, 's4b'); + assert.equal(state.user_info_class, null); + assert.deepEqual(state.user_info_flags, { yes_people: 0, yes_digital: 0, no: 0 }); + assert.equal(state.turn_count, 0); + assert.equal(state.valseek_count, 0); + assert.equal(state.valseek_flag, 0); + }); +}); + +// --- Tier-2 cross-session alert --- +// +// Fires at SessionStart when last 3 end records all have user_info_class='no' +// AND each session had at least one HIGH_STAKES_DOMAINS hit. + +function writeFixture(dir, records) { + const lines = records.map(r => JSON.stringify(r)).join('\n') + '\n'; + writeFileSync(join(dir, 'sessions.jsonl'), lines); +} + +describe('tier-2 cross-session isolation alert', () => { + it('fires when 3 prior end records all show no + high-stakes', () => { + dir = setupTestDir(); + writeFixture(dir, [ + { session_id: 'p1', duration_min: 30, user_info_class: 'no', domain_context: ['legal'] }, + { session_id: 'p2', duration_min: 25, user_info_class: 'no', domain_context: ['health'] }, + { session_id: 'p3', duration_min: 40, user_info_class: 'no', domain_context: ['parenting', 'financial'] }, + ]); + const out = runHook('session-start.mjs', { session_id: 'snew', cwd: '/tmp' }, dir); + assert.match(out.hookSpecificOutput.additionalContext, /tier-2/); + }); + + it('does NOT fire when only 2 prior "no" records exist', () => { + dir = setupTestDir(); + writeFixture(dir, [ + { session_id: 'p1', duration_min: 30, user_info_class: 'no', domain_context: ['legal'] }, + { session_id: 'p2', duration_min: 30, user_info_class: 'no', domain_context: ['health'] }, + ]); + const out = runHook('session-start.mjs', { session_id: 'snew2', cwd: '/tmp' }, dir); + const text = out.hookSpecificOutput.additionalContext; + assert.ok(!/tier-2/.test(text), 'tier-2 must require N consecutive sessions'); + }); + + it('does NOT fire when one record has yes_people class', () => { + dir = setupTestDir(); + writeFixture(dir, [ + { session_id: 'p1', duration_min: 30, user_info_class: 'no', domain_context: ['legal'] }, + { session_id: 'p2', duration_min: 30, user_info_class: 'yes_people', domain_context: ['health'] }, + { session_id: 'p3', duration_min: 30, user_info_class: 'no', domain_context: ['financial'] }, + ]); + const out = runHook('session-start.mjs', { session_id: 'snew3', cwd: '/tmp' }, dir); + assert.ok(!/tier-2/.test(out.hookSpecificOutput.additionalContext)); + }); + + it('does NOT fire when any session is in low-stakes domain', () => { + dir = setupTestDir(); + writeFixture(dir, [ + { session_id: 'p1', duration_min: 30, user_info_class: 'no', domain_context: ['legal'] }, + { session_id: 'p2', duration_min: 30, user_info_class: 'no', domain_context: ['consumer'] }, + { session_id: 'p3', duration_min: 30, user_info_class: 'no', domain_context: ['health'] }, + ]); + const out = runHook('session-start.mjs', { session_id: 'snew4', cwd: '/tmp' }, dir); + assert.ok(!/tier-2/.test(out.hookSpecificOutput.additionalContext)); + }); + + it('handles v1.1.0 records with string domain_context (backward compat)', () => { + dir = setupTestDir(); + writeFixture(dir, [ + { session_id: 'p1', duration_min: 30, user_info_class: 'no', domain_context: 'health' }, // string shape + { session_id: 'p2', duration_min: 30, user_info_class: 'no', domain_context: ['legal'] }, + { session_id: 'p3', duration_min: 30, user_info_class: 'no', domain_context: ['parenting'] }, + ]); + const out = runHook('session-start.mjs', { session_id: 'snew5', cwd: '/tmp' }, dir); + assert.match(out.hookSpecificOutput.additionalContext, /tier-2/); + }); });