diff --git a/plugins/ai-psychosis/commands/interaction-report.md b/plugins/ai-psychosis/commands/interaction-report.md index cec5926..9eacbf8 100644 --- a/plugins/ai-psychosis/commands/interaction-report.md +++ b/plugins/ai-psychosis/commands/interaction-report.md @@ -109,13 +109,16 @@ The file contains two record types interleaved: ``` **End records** — have `end`, `duration_min`, `tool_count`, `edit_count`, `flags`, -and (v1.1.0+) `domain_context` at top level plus `pushback` inside `flags`: +and (v1.1.0+) `domain_context` at top level plus `pushback` inside `flags`. +v1.2 records additionally carry `user_info_class`, `valseek_count`, +`turn_count`, and `domain_context` is always an array: ```json -{"session_id":"abc","start":"2026-04-05T10:00:00Z","end":"2026-04-05T11:35:00Z","duration_min":95,"tool_count":47,"edit_count":12,"domain_context":"relationship","flags":{"dependency":2,"escalation":0,"fatigue":1,"validation":1,"pushback":3}} +{"session_id":"abc","start":"2026-04-05T10:00:00Z","end":"2026-04-05T11:35:00Z","duration_min":95,"tool_count":47,"edit_count":12,"domain_context":["relationship","health"],"user_info_class":"no","valseek_count":3,"turn_count":18,"flags":{"dependency":2,"escalation":0,"fatigue":1,"validation":1,"pushback":3}} ``` Records produced by v1.0.0 omit `domain_context` and `flags.pushback`. -Treat missing values as `null` / `0` — never as `NaN`. +v1.1.0 records have `domain_context` as a string; v1.2 records have it as +an array. Treat missing values as `null` / `0` — never as `NaN`. **Error records** — have `note: "no_state_file"`. Ignore these. @@ -144,11 +147,20 @@ node hooks/scripts/report-reader.mjs ${CLAUDE_PLUGIN_DATA}/sessions.jsonl The script outputs a JSON object with the following fields: - `pushback_total` — sum of `flags.pushback` across all end records -- `relationship_domain_count` — count of records where `domain_context === 'relationship'` +- `relationship_domain_count` — count of records where `domain_context` includes 'relationship' - `null_domain_count`, `other_domain_count` — remaining domain buckets - `total_end_records` — number of complete sessions - `flags_total` — totals for dependency / escalation / fatigue / validation / pushback -- `schema_version.v1_0_records` / `v1_1_records` — backward-compat counters +- `schema_version.v1_0_records` / `v1_1_records` / `v1_2_records` — backward-compat counters +- **v1.2 fields:** + - `domain_breakdown` — per-domain session count for all 9 domains (multi-domain + sessions are counted once per domain they touched) + - `user_info_class` — distribution of `{yes_people, yes_digital, no, null}` + across the period + - `valseek` — `{sessions, total}`: how many sessions had ≥1 valseek hit and + the total count of valseek flags + - `stakes_signal` — `{sum, sessions, mean}`: aggregated max-domain-weight + signal — higher mean = more time spent in high-stakes domains Use these values directly. The reader handles backward-compatibility with v1.0.0 records (missing `pushback` / `domain_context`) and never produces NaN. @@ -238,14 +250,67 @@ this automatically — it is a self-assessment prompt, not a measurement. ### Domain context +When `domain_breakdown` is available (v1.2 records present), surface the +per-domain count instead of the v1.1.0 binary table. Multi-domain sessions +are counted once per domain. + | Domain | Sessions | |--------|----------| -| Relationship-flavored | {relationship_domain_count} | -| Other / not classified | {null_domain_count + other_domain_count} | +| Relationship | {domain_breakdown.relationship} | +| Health | {domain_breakdown.health} | +| Legal | {domain_breakdown.legal} | +| Parenting | {domain_breakdown.parenting} | +| Financial | {domain_breakdown.financial} | +| Professional | {domain_breakdown.professional} | +| Spirituality | {domain_breakdown.spirituality} | +| Consumer | {domain_breakdown.consumer} | +| Personal development | {domain_breakdown.personal_dev} | -Domain detection is heuristic and conservative. A "relationship" tag means -patterns associated with relational decision support appeared at least once -during the session, not that the entire session was about relationships. +Skip rows with count 0 unless none have data, in which case show +"No domain context recorded." Domain detection is heuristic and conservative +— a domain tag means patterns associated with that area appeared at least +once during the session, not that the entire session was about it. + +### User information dimension (v1.2) + +Surface this section ONLY when `schema_version.v1_2_records > 0`. + +| Class | Sessions | Note | +|-------|----------|------| +| `yes_people` | {user_info_class.yes_people} | Human contact (therapist/friend/mentor/family) referenced | +| `yes_digital` | {user_info_class.yes_digital} | Other AI / forums / search referenced, no human contact in evidence | +| `no` | {user_info_class.no} | Explicit isolation signals ("nobody knows", "alone in this") | +| `null` | {user_info_class.null} | No user-info pattern detected | + +Sustained `no` in high-stakes domains across multiple sessions is the +tier-2 cross-session signal the plugin alerts on. + +### Validation-seeking (v1.2) + +Surface this section ONLY when `schema_version.v1_2_records > 0`. + +| Metric | Value | +|--------|-------| +| Sessions with ≥1 valseek hit | {valseek.sessions} of {v1_2_records} | +| Total valseek flags | {valseek.total} | + +Validation-seeking is distinct from the existing "right?" tic counter. +It targets reality-testing ("am I crazy?"), pre-committed stance + confirmation, +and side-taking pressing. + +### Stakes signal (v1.2) + +Surface this section ONLY when `schema_version.v1_2_records > 0` and +`stakes_signal.sessions > 0`. + +| Metric | Value | +|--------|-------| +| Mean stakes weight | {stakes_signal.mean} | +| Sessions in domain context | {stakes_signal.sessions} | + +Stakes signal is the per-session max domain weight (1.0 = baseline, +1.5 = legal/parenting/health/financial). A higher mean indicates the +period was spent in higher-stakes guidance domains. ### Tool Usage (top 10) diff --git a/plugins/ai-psychosis/tests/interaction-report.test.mjs b/plugins/ai-psychosis/tests/interaction-report.test.mjs index afd64a0..d63d7af 100644 --- a/plugins/ai-psychosis/tests/interaction-report.test.mjs +++ b/plugins/ai-psychosis/tests/interaction-report.test.mjs @@ -23,6 +23,17 @@ function runReader(jsonlContent) { } } +function runReaderRaw(jsonlContent) { + const dir = mkdtempSync(join(tmpdir(), 'ia-report-')); + const path = join(dir, 'sessions.jsonl'); + writeFileSync(path, jsonlContent); + try { + return execSync(`node ${SCRIPT} ${path}`, { encoding: 'utf8', timeout: 5000 }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + test('pushback_total matches sum across v1.1.0 records', () => { const fixture = [ { session_id: 'a', start: '2026-04-10T10:00:00Z', end: '2026-04-10T11:00:00Z', @@ -166,3 +177,22 @@ test('backward-compat: v1.0.0 records without pushback/domain do not produce NaN assert.equal(result.flags_total.dependency, 1); assert.equal(result.flags_total.fatigue, 1); }); + +test('report-reader stdout surfaces v1.2 field names (SC-12)', () => { + // Run reader against a v1.2 fixture and assert stdout contains the field + // names that /interaction-report references in its output template. + const fixture = [ + { session_id: 'a', duration_min: 30, + domain_context: ['legal', 'health'], + user_info_class: 'no', valseek_count: 4, turn_count: 22, + flags: { dependency: 0, escalation: 0, fatigue: 0, validation: 0, pushback: 1 } }, + ]; + const stdout = runReaderRaw(fixture.map(o => JSON.stringify(o)).join('\n') + '\n'); + // SC-12 specifies these field names must be present in the report output: + assert.ok(stdout.includes('user_info_class'), 'stdout missing user_info_class field'); + assert.ok(stdout.includes('valseek'), 'stdout missing valseek aggregation'); + assert.ok(stdout.includes('stakes_signal'), 'stdout missing stakes_signal aggregation'); + // Also assert at least one new domain name (legal) appears in domain_breakdown. + assert.ok(stdout.includes('legal'), 'stdout missing legal domain in breakdown'); + assert.ok(stdout.includes('domain_breakdown'), 'stdout missing domain_breakdown structure'); +});