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, pushback_count: 0, domain_context: null, 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')); }); }); // --- v1.1.0 pushback + domain regex (regex-only unit tests) --- // Local copies of patterns in hooks/scripts/prompt-analyzer.mjs. // Step 3 adds integration tests via runPrompt; integration tests catch // pattern divergence between source and tests. const pbReactivePatterns = [ /^are you sure\??/i, /\bi'?m not convinced\b/i, /\bthat doesn'?t (?:seem|feel) right\b/i, /\bthat'?s not (?:quite )?what i meant\b/i, /\blet me add (?:some )?context\b/i, /\bactually,? (?:my situation|i)\b/i, /(?:^|[.!?]\s+)i (?:believe|think) (?:you'?re|that'?s) wrong\b/i, /\bi don'?t agree(?: with you)?\b/i, /\bare you absolutely sure\b/i, ]; const pbPreemptivePatterns = [ /\bsteelman\b/i, /\bplay (?:the )?devil'?s advocate\b/i, /\bargue against (?:this|my)\b/i, ]; const domainRelationshipPatterns = [ /\b(?:my|our) (?:partner|spouse|wife|husband|girlfriend|boyfriend)\b/i, /\bin our relationship\b/i, /\b(?:dating|breakup|divorce)\b/i, /\bromantic(?:ally)? (?:involved|interested)\b/i, ]; function matchesAny(patterns, text) { return patterns.some((p) => p.test(text)); } describe('pushback reactive patterns', () => { it('matches "are you sure?"', () => assert.ok(matchesAny(pbReactivePatterns, 'are you sure?'))); it('does not match "tell me what to do" (no pushback)', () => assert.equal(matchesAny(pbReactivePatterns, 'tell me what to do'), false)); it("matches \"i'm not convinced\"", () => assert.ok(matchesAny(pbReactivePatterns, "i'm not convinced this works"))); it('does not match "i am convinced" (no negation)', () => assert.equal(matchesAny(pbReactivePatterns, 'i am convinced this works'), false)); it('matches "that doesn\'t seem right"', () => assert.ok(matchesAny(pbReactivePatterns, "that doesn't seem right to me"))); it('does not match "that seems right" (positive sense)', () => assert.equal(matchesAny(pbReactivePatterns, 'that seems right to me'), false)); it('matches "that\'s not what I meant"', () => assert.ok(matchesAny(pbReactivePatterns, "that's not what I meant by that"))); it('does not match "I meant exactly that"', () => assert.equal(matchesAny(pbReactivePatterns, 'I meant exactly that'), false)); it('matches "let me add context"', () => assert.ok(matchesAny(pbReactivePatterns, 'let me add context — the issue is X'))); it('does not match "I added context to the function"', () => assert.equal(matchesAny(pbReactivePatterns, 'I added context to the function'), false)); it('matches "actually, my situation is different"', () => assert.ok(matchesAny(pbReactivePatterns, 'actually, my situation is different'))); it('does not match "actually that approach works"', () => assert.equal(matchesAny(pbReactivePatterns, 'actually that approach works'), false)); it("matches \"I think you're wrong\"", () => assert.ok(matchesAny(pbReactivePatterns, "I think you're wrong about this"))); it("does not match \"I think we're wrong\" (different pronoun)", () => assert.equal(matchesAny(pbReactivePatterns, "I think we're wrong here"), false)); it("matches \"I don't agree\"", () => assert.ok(matchesAny(pbReactivePatterns, "I don't agree with that conclusion"))); it('does not match "I agree with you"', () => assert.equal(matchesAny(pbReactivePatterns, 'I agree with you fully'), false)); it('matches "are you absolutely sure"', () => assert.ok(matchesAny(pbReactivePatterns, 'are you absolutely sure about that'))); it('does not match "we are sure of the answer" (no questioning frame)', () => assert.equal(matchesAny(pbReactivePatterns, 'we are sure of the answer'), false)); }); describe('pushback preemptive patterns', () => { it('matches "steelman"', () => assert.ok(matchesAny(pbPreemptivePatterns, 'please steelman this argument'))); it('does not match "steel manufacturing" (no whole-word match)', () => assert.equal(matchesAny(pbPreemptivePatterns, 'the steel manufacturing report'), false)); it("matches \"play devil's advocate\"", () => assert.ok(matchesAny(pbPreemptivePatterns, "can you play devil's advocate here"))); it('does not match "play music" (different verb object)', () => assert.equal(matchesAny(pbPreemptivePatterns, 'play music while coding'), false)); it('matches "argue against this"', () => assert.ok(matchesAny(pbPreemptivePatterns, 'argue against this proposal'))); it('does not match "they argue with each other"', () => assert.equal(matchesAny(pbPreemptivePatterns, 'they argue with each other'), false)); }); describe('domain relationship patterns', () => { it('matches "my partner won\'t listen"', () => assert.ok(matchesAny(domainRelationshipPatterns, "my partner won't listen"))); it('matches "in our relationship"', () => assert.ok(matchesAny(domainRelationshipPatterns, 'in our relationship things changed'))); it('matches "considering divorce"', () => assert.ok(matchesAny(domainRelationshipPatterns, 'considering divorce after years'))); it('matches "romantically involved"', () => assert.ok(matchesAny(domainRelationshipPatterns, 'we are romantically involved'))); it('does not match "function relationship between input and output" (technical false-positive)', () => assert.equal(matchesAny(domainRelationshipPatterns, 'function relationship between input and output'), false)); it('does not match "database relationship mapping" (technical false-positive)', () => assert.equal(matchesAny(domainRelationshipPatterns, 'database relationship mapping'), false)); it('does not match "the data is updating" (no dating word boundary)', () => assert.equal(matchesAny(domainRelationshipPatterns, 'the data is updating in real time'), false)); it('does not match "romantic comedy film" (no involved/interested suffix)', () => assert.equal(matchesAny(domainRelationshipPatterns, 'watching a romantic comedy film'), false)); }); // --- v1.1.0 integration: pushback + valence + domain through prompt-analyzer.mjs --- describe('pushback integration (state accumulation + same-invocation valence)', () => { it('counts reactive pushback alone (no fatigue/escalation)', () => { const s = runPrompt('are you sure?'); assert.equal(s.pushback_count, 1); assert.equal(s.fatigue_flags, 0); assert.equal(s.esc_flags, 0); }); it('counts preemptive pushback alone', () => { const s = runPrompt('please steelman this argument'); assert.equal(s.pushback_count, 1); }); it('SUPPRESSES pushback when fatigue marker is in same invocation (valence guard)', () => { const s = runPrompt("are you sure? I'm exhausted by all this"); assert.equal(s.pushback_count, 0, 'pushback must be suppressed when fatigue is co-present'); assert.equal(s.fatigue_flags, 1); }); it('sets domain_context to ["relationship"] on positive match (v1.2 array shape)', () => { const s = runPrompt("my partner won't listen to me"); assert.deepEqual(s.domain_context, ['relationship']); }); it('keeps domain_context null on technical "function relationship" (false-positive guard)', () => { const s = runPrompt('function relationship between input and output'); // No domainHit → state.domain_context stays as fresh-state null (untouched). assert.equal(s.domain_context, null); }); }); // --- v1.2 pushback alert contract (domain-aware re-contextualization) --- // // Step 12 of v1.2.0 ADDS the pushback alert with domain awareness baked in. // Replaces the v1.1.0 "count but never alert" contract test. // // Behavior: // - HIGH_SYCOPHANCY_DOMAINS (relationship, spirituality): alert at count >= 2 // - INFO_DOMAINS (legal, parenting, health, financial, professional): NO alert // — pushback in info-seeking domains is healthy self-advocacy. // - Empty / unknown domain: conservative default alert. function runPromptCapture(prompt, stateOverrides = {}) { dir = setupTestDir(); createStateFile(dir, 'p1', { ...freshState(), ...stateOverrides }); const out = runHook('prompt-analyzer.mjs', { session_id: 'p1', prompt }, dir); const state = readState(dir, 'p1'); return { state, out }; } describe('pushback alert (v1.2 domain-aware contract)', () => { it('accumulates pushback_count over 5 sequential prompts', () => { dir = setupTestDir(); createStateFile(dir, 'p1', { ...freshState(), domain_context: ['relationship'] }); const prompts = [ 'are you sure?', "I'm not convinced", "that doesn't seem right", "actually, I think you're wrong", "are you absolutely sure?", ]; for (const p of prompts) { runHook('prompt-analyzer.mjs', { session_id: 'p1', prompt: p }, dir); } const s = readState(dir, 'p1'); assert.equal(s.pushback_count, 5, 'count accumulates across calls'); }); it('3 pushbacks + relationship → alert (HIGH_SYCOPHANCY)', () => { const { state, out } = runPromptCapture('are you absolutely sure?', { domain_context: ['relationship'], pushback_count: 2, // becomes 3 }); assert.equal(state.pushback_count, 3); assert.match(out.hookSpecificOutput.additionalContext, /pushback re-contextualization/); }); it('3 pushbacks + parenting → NO alert (INFO_DOMAIN, healthy self-advocacy)', () => { const { out } = runPromptCapture("I'm not convinced", { domain_context: ['parenting'], pushback_count: 2, }); // Suppress pushback alert; nothing else should fire here either. assert.equal(out.hookSpecificOutput, undefined, 'parenting pushback is healthy self-advocacy — no alert'); }); it('3 pushbacks + [relationship, legal] → alert (mixed: any HIGH_SYCOPHANCY wins)', () => { const { out } = runPromptCapture('are you absolutely sure?', { domain_context: ['relationship', 'legal'], pushback_count: 2, }); assert.match(out.hookSpecificOutput.additionalContext, /pushback re-contextualization/); }); it('3 pushbacks + empty domain → alert (conservative default)', () => { const { out } = runPromptCapture('are you absolutely sure?', { domain_context: [], pushback_count: 2, }); assert.match(out.hookSpecificOutput.additionalContext, /pushback/); }); it('1 pushback + relationship → NO alert (sub-threshold)', () => { const { out } = runPromptCapture("are you sure?", { domain_context: ['relationship'], pushback_count: 0, }); assert.equal(out.hookSpecificOutput, undefined, 'sub-threshold (count<2) — no alert even in HIGH_SYCOPHANCY'); }); it('5 pushbacks across info-only domains [legal, health] → NO alert', () => { const { out } = runPromptCapture("I'm not convinced", { domain_context: ['legal', 'health'], pushback_count: 4, }); assert.equal(out.hookSpecificOutput, undefined, 'all-info domains never alert pushback regardless of count'); }); });