From 297867f847e545f00db5ddaac782293f6330e395 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Mon, 6 Apr 2026 20:46:09 +0200 Subject: [PATCH] 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 --- .../ai-psychosis/.claude-plugin/plugin.json | 8 + plugins/ai-psychosis/.gitignore | 18 + plugins/ai-psychosis/CHANGELOG.md | 130 ++++++++ plugins/ai-psychosis/CLAUDE.md | 85 +++++ plugins/ai-psychosis/LICENSE | 21 ++ .../commands/interaction-report.md | 256 ++++++++++++++ plugins/ai-psychosis/hooks/hooks.json | 48 +++ plugins/ai-psychosis/hooks/scripts/lib.mjs | 239 +++++++++++++ .../hooks/scripts/prompt-analyzer.mjs | 140 ++++++++ .../hooks/scripts/session-end.mjs | 67 ++++ .../hooks/scripts/session-start.mjs | 69 ++++ .../hooks/scripts/tool-tracker.mjs | 166 ++++++++++ .../ai-psychosis/skills/ai-psychosis/SKILL.md | 54 +++ plugins/ai-psychosis/tests/privacy.test.mjs | 44 +++ .../tests/prompt-analyzer.test.mjs | 313 ++++++++++++++++++ .../ai-psychosis/tests/session-end.test.mjs | 66 ++++ .../ai-psychosis/tests/session-start.test.mjs | 49 +++ plugins/ai-psychosis/tests/test-helper.mjs | 53 +++ .../ai-psychosis/tests/tool-tracker.test.mjs | 94 ++++++ 19 files changed, 1920 insertions(+) create mode 100644 plugins/ai-psychosis/.claude-plugin/plugin.json create mode 100644 plugins/ai-psychosis/.gitignore create mode 100644 plugins/ai-psychosis/CHANGELOG.md create mode 100644 plugins/ai-psychosis/CLAUDE.md create mode 100644 plugins/ai-psychosis/LICENSE create mode 100644 plugins/ai-psychosis/commands/interaction-report.md create mode 100644 plugins/ai-psychosis/hooks/hooks.json create mode 100644 plugins/ai-psychosis/hooks/scripts/lib.mjs create mode 100644 plugins/ai-psychosis/hooks/scripts/prompt-analyzer.mjs create mode 100644 plugins/ai-psychosis/hooks/scripts/session-end.mjs create mode 100644 plugins/ai-psychosis/hooks/scripts/session-start.mjs create mode 100644 plugins/ai-psychosis/hooks/scripts/tool-tracker.mjs create mode 100644 plugins/ai-psychosis/skills/ai-psychosis/SKILL.md create mode 100644 plugins/ai-psychosis/tests/privacy.test.mjs create mode 100644 plugins/ai-psychosis/tests/prompt-analyzer.test.mjs create mode 100644 plugins/ai-psychosis/tests/session-end.test.mjs create mode 100644 plugins/ai-psychosis/tests/session-start.test.mjs create mode 100644 plugins/ai-psychosis/tests/test-helper.mjs create mode 100644 plugins/ai-psychosis/tests/tool-tracker.test.mjs diff --git a/plugins/ai-psychosis/.claude-plugin/plugin.json b/plugins/ai-psychosis/.claude-plugin/plugin.json new file mode 100644 index 0000000..d4c1e49 --- /dev/null +++ b/plugins/ai-psychosis/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "ai-psychosis", + "version": "1.0.0", + "description": "Meta-awareness tools for healthy AI interaction patterns. Detects reinforcement loops, scope escalation, narrative crystallization, and other compulsive patterns.", + "author": "Kjell Tore Guttormsen", + "license": "MIT", + "repository": "https://git.fromaitochitta.com/open/ai-psychosis" +} diff --git a/plugins/ai-psychosis/.gitignore b/plugins/ai-psychosis/.gitignore new file mode 100644 index 0000000..3853ea4 --- /dev/null +++ b/plugins/ai-psychosis/.gitignore @@ -0,0 +1,18 @@ +# Environment +.env +.env.* + +# Claude Code +*.local.md +.claude/ + +# macOS +.DS_Store + +# Node (for future use) +node_modules/ +dist/ + +# Data/logs +data/ +*.jsonl diff --git a/plugins/ai-psychosis/CHANGELOG.md b/plugins/ai-psychosis/CHANGELOG.md new file mode 100644 index 0000000..1f87c75 --- /dev/null +++ b/plugins/ai-psychosis/CHANGELOG.md @@ -0,0 +1,130 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [1.0.0] — 2026-04-05 + +### Added + +- **Layer 4: Contemplative references** — conditional section in + `/interaction-report` when flags are elevated (total >= 5 or fatigue >= 2) + and `layer4: true`. Points to Miracle of Mind by Sadhguru. +- **Automated test suite** — 73 cases using `node:test` (zero npm deps): + session-start (4), prompt-analyzer (56), tool-tracker (8), + session-end (4), privacy canary (1) + +### Fixed + +- Dependency regex `you understand me` no longer matches "merging" (added `\b`) + +### Changed + +- CLAUDE.md testing section updated for automated tests +- Deprecated bash scripts removed (available in git history) +- All "Known gaps" from v0.4.0 resolved + +## [0.4.0] — 2026-04-05 + +### Changed + +- **All hooks migrated from bash+jq to Node.js** — full cross-platform + support (macOS, Linux, Windows) + - `lib.sh` → `lib.mjs` (shared library, 22 functions) + - `session-start.sh` → `session-start.mjs` + - `prompt-analyzer.sh` → `prompt-analyzer.mjs` (23 regex patterns) + - `tool-tracker.sh` → `tool-tracker.mjs` + - `session-end.sh` → `session-end.mjs` +- hooks.json now invokes `node ...mjs` instead of `bash ...sh` +- Zero npm dependencies — Node.js stdlib only (`fs`, `path`, `os`) +- Bash scripts deprecated (kept for reference, marked with DEPRECATED) +- Dependencies reduced: bash and jq no longer required +- All documentation updated for Node.js migration + +### Fixed + +- Data path fallback now matches documented path + (`~/.claude/plugins/data/ai-psychosis`) +- `.claude/` directory added to `.gitignore` +- Private repo path removed from design brief +- CONTRIBUTING.md line reference corrected +- README now links to CONTRIBUTING.md +- plugin.json includes author, license, repository fields + +## [0.3.0] — 2026-04-05 + +### Added + +- **Layer 3: Interaction reports** — `/interaction-report` slash command + for aggregated session statistics + - Time periods: `weekly` (default), `monthly`, `all` + - Overview: session count, avg duration, tool calls, edit ratio + - Pattern flags: dependency, escalation, fatigue, validation frequency + - Tool usage distribution (top 10) + - Daily activity breakdown + - Trend comparison vs previous period +- `commands/interaction-report.md` — pure markdown command, no script + dependencies (cross-platform: macOS, Linux, Windows) +- Layer 3 respects `layer3: true/false` in + `.claude/ai-psychosis.local.md` (opt-in, off by default) + +### Changed + +- README updated with Layer 3 usage instructions +- Platform compatibility expanded: Layer 3 works on Windows +- Version bumped to 0.3.0 + +## [0.2.0] — 2026-04-05 + +### Added + +- **Layer 2: Programmatic pattern detection** — four hooks measuring session + time, tool usage, burst patterns, and language flags + - `session-start.sh` — daily session count, late-night detection + - `prompt-analyzer.sh` — dependency, escalation, fatigue, and + validation-seeking pattern flags (prompt text never stored) + - `tool-tracker.sh` — event logging, edit ratio, burst detection, + progressive alerts with cooldown + - `session-end.sh` — session finalization, JSONL record, state cleanup +- `lib.sh` — shared library with thresholds, state management, cooldown + logic, and layer configuration +- Per-project layer configuration via + `.claude/ai-psychosis.local.md` +- `require_layer()` guard in all hook scripts — layers are opt-in/out +- MIT LICENSE file +- `matcher` field in hooks.json for schema compliance + +### Changed + +- hooks.json now registers 4 events (was 2) +- `DATA_DIR` fallback hardened to `~/.claude/data/ai-psychosis` +- README rewritten with architecture diagram, research background, + privacy section, threshold reference tables +- Version bumped to 0.2.0 + +### Removed + +- `periodic-reminder.sh` — replaced by `tool-tracker.sh` +- `session-awareness.sh` — replaced by `session-start.sh` + +## [0.1.0] — 2026-04-04 + +### Added + +- **Layer 1: Behavioral instructions** — `SKILL.md` with 5 rules and 5 + named patterns (reinforcement loop, scope escalation, narrative + crystallization, emotional dependency, session overuse) +- `periodic-reminder.sh` — re-injects awareness every 25 tool calls +- `session-awareness.sh` — SessionStart context injection +- Plugin manifest (`plugin.json`) +- Design document (`docs/ai-ai-psychosis-brief_1.md`) + +## Known gaps + +- No CI pipeline +- Single-user plugin — no multi-user patterns considered + +[1.0.0]: https://git.fromaitochitta.com/open/ai-psychosis/compare/v0.4.0...v1.0.0 +[0.4.0]: https://git.fromaitochitta.com/open/ai-psychosis/compare/v0.3.0...v0.4.0 +[0.3.0]: https://git.fromaitochitta.com/open/ai-psychosis/compare/v0.2.0...v0.3.0 +[0.2.0]: https://git.fromaitochitta.com/open/ai-psychosis/compare/v0.1.0...v0.2.0 +[0.1.0]: https://git.fromaitochitta.com/open/ai-psychosis/releases/tag/v0.1.0 diff --git a/plugins/ai-psychosis/CLAUDE.md b/plugins/ai-psychosis/CLAUDE.md new file mode 100644 index 0000000..cd2d63c --- /dev/null +++ b/plugins/ai-psychosis/CLAUDE.md @@ -0,0 +1,85 @@ +# Interaction Awareness — Developer Reference + +Claude Code plugin for AI interaction pattern awareness. + +## Architecture + +Four layers, each building on the previous: + +- **Layer 1** (`skills/`) — SKILL.md behavioral overrides. Always active. +- **Layer 2** (`hooks/scripts/`) — Programmatic detection via 4 hook events. + Node.js (`.mjs`), cross-platform. Writes JSONL metadata to `${CLAUDE_PLUGIN_DATA}`. +- **Layer 3** (`commands/`) — User-triggered reports from Layer 2 data. Opt-in. +- **Layer 4** (`commands/interaction-report.md` Step 9) — Contemplative references. Opt-in. + +## Key files + +| File | Purpose | +|------|---------| +| `hooks/scripts/lib.mjs` | Shared library: stdin, paths, thresholds, state, cooldowns, layer guards | +| `hooks/scripts/session-start.mjs` | SessionStart: register session, count daily, night check | +| `hooks/scripts/prompt-analyzer.mjs` | UserPromptSubmit: pattern flags (NEVER logs prompt text) | +| `hooks/scripts/tool-tracker.mjs` | PostToolUse: events, edit ratio, burst, alerts | +| `hooks/scripts/session-end.mjs` | SessionEnd: finalize JSONL, cleanup state | +| `hooks/hooks.json` | Hook event registration (4 events) | +| `skills/ai-psychosis/SKILL.md` | Layer 1 behavioral instructions | +| `commands/interaction-report.md` | Layer 3 slash command: `/interaction-report [weekly\|monthly\|all]` | + +Legacy bash scripts were removed in v1.0 (available in git history). + +## Data storage + +``` +${CLAUDE_PLUGIN_DATA}/ +├── sessions.jsonl Compact JSONL, one record per session +├── events.jsonl {ts, session_id, tool_name} per tool call +└── state/ + └── {session_id}.json Live state during active session +``` + +State files are created at SessionStart and deleted at SessionEnd. + +## Hard constraints + +- **Cross-platform** — Node.js only, no bash/jq dependency +- **Privacy** — prompt text NEVER written to disk. Boolean flags only. +- **Performance** — hooks must complete in <100ms +- **Non-blocking** — never exit 2, never require confirmation +- **No network** — everything local +- **Zero npm dependencies** — Node.js stdlib only (fs, path, os) + +## Layer configuration + +Global config at `~/.claude/ai-psychosis.local.md`, or per-project override at `/.claude/ai-psychosis.local.md`: + +```yaml +--- +layer2: true # default on +layer3: false # default off +layer4: false # default off +--- +``` + +`requireLayer(N)` in lib.mjs exits with `{"continue": true}` if layer N is disabled. + +## Testing + +Automated test suite using `node:test` (73 cases, zero npm dependencies): + +```bash +node --test tests/*.test.mjs +``` + +| File | Cases | Coverage | +|------|-------|----------| +| `tests/session-start.test.mjs` | 4 | State init, JSONL, missing sid | +| `tests/prompt-analyzer.test.mjs` | 56 | 25 patterns × 2 + 6 thresholds | +| `tests/tool-tracker.test.mjs` | 8 | Counting, burst, reminders | +| `tests/session-end.test.mjs` | 4 | Finalize, duration, flags | +| `tests/privacy.test.mjs` | 1 | Canary string never on disk | + +## Conventions + +- Conventional Commits: `type(scope): description` +- English for all code, comments, and documentation +- Norwegian for project-internal communication diff --git a/plugins/ai-psychosis/LICENSE b/plugins/ai-psychosis/LICENSE new file mode 100644 index 0000000..1105208 --- /dev/null +++ b/plugins/ai-psychosis/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Kjell Tore Guttormsen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/ai-psychosis/commands/interaction-report.md b/plugins/ai-psychosis/commands/interaction-report.md new file mode 100644 index 0000000..9da5002 --- /dev/null +++ b/plugins/ai-psychosis/commands/interaction-report.md @@ -0,0 +1,256 @@ +--- +name: interaction-report +description: Interaction pattern report from Layer 2 session data +argument-hint: "[weekly|monthly|all]" +allowed-tools: [Read, Bash, Glob] +--- + +# Interaction Awareness Report + +You are generating an interaction awareness report from JSONL session data. + +## Step 1 — Layer guard + +Read the file `.claude/ai-psychosis.local.md` in the current working +directory. If the file does not exist, or if its YAML frontmatter does not +contain `layer3: true`, stop and output: + +``` +Layer 3 (reports) is not enabled for this project. + +To enable, create `.claude/ai-psychosis.local.md`: + + --- + layer2: true + layer3: true + layer4: false + --- + +Then restart Claude Code. +``` + +Do not continue past this step if Layer 3 is not enabled. + +Also note the value of `layer4` (true or false) — you will need it in Step 9. + +## Step 2 — Parse arguments + +The time period is determined by `$ARGUMENTS`: + +| Argument | Period | Cutoff | +|----------|--------|--------| +| *(empty)* | Last 7 days | Today minus 7 days | +| `weekly` | Last 7 days | Today minus 7 days | +| `monthly` | Last 30 days | Today minus 30 days | +| `all` | All data | No cutoff | + +If `$ARGUMENTS` is anything else, output: + +``` +Usage: /interaction-report [weekly|monthly|all] + + weekly Last 7 days (default) + monthly Last 30 days + all All recorded data +``` + +## Step 3 — Locate data files + +Run via Bash: `echo $CLAUDE_PLUGIN_DATA` + +If the result is empty, use the fallback path `~/.claude/plugins/data/ai-psychosis`. + +Check that both files exist: +- `{data_dir}/sessions.jsonl` +- `{data_dir}/events.jsonl` + +If neither file exists, output: + +``` +No interaction data found. + +Layer 2 (programmatic detection) collects data during active sessions. +Ensure Layer 2 is enabled and use Claude Code normally — data accumulates +automatically. Then run /interaction-report again. +``` + +If only `events.jsonl` is missing, proceed with sessions data only and note +"Tool usage data not available" in the report. + +## Step 4 — Read data + +### Size check + +Run via Bash: `wc -l {data_dir}/sessions.jsonl {data_dir}/events.jsonl 2>/dev/null || true` + +If a file does not exist, skip it and treat its line count as 0. + +### Read sessions.jsonl + +If the file has fewer than 1000 lines, read the entire file. +If larger, read the last 1000 lines (via Bash: `tail -n 1000 {data_dir}/sessions.jsonl`). + +### Read events.jsonl + +If the file has fewer than 5000 lines, read the entire file. +If larger and period is `weekly`: read the last 5000 lines. +If larger and period is `monthly` or `all`: read the last 10000 lines and note +"Events data sampled (last N entries)" in the report. + +## Step 5 — Parse and filter records + +### sessions.jsonl record types + +The file contains two record types interleaved: + +**Start records** — have `hour` and `is_late_night`, but NO `end` or `duration_min`: +```json +{"session_id":"abc","start":"2026-04-05T10:00:00Z","hour":10,"is_late_night":false} +``` + +**End records** — have `end`, `duration_min`, `tool_count`, `edit_count`, `flags`: +```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,"flags":{"dependency":2,"escalation":0,"fatigue":1,"validation":1}} +``` + +**Error records** — have `note: "no_state_file"`. Ignore these. + +### Filtering + +For the selected time period, filter records where the `start` field is +greater than or equal to the cutoff date string (ISO timestamps sort +lexicographically — string comparison works correctly). + +Separate start records from end records: +- **End records** (have `duration_min`): use for duration, tools, flags +- **Start records** (have `is_late_night`): use for late-night count + +### events.jsonl + +Filter events where `ts` >= cutoff date string. Group by `tool_name` and count. + +## Step 6 — Compute statistics + +From **end records**: +- Total sessions (count of end records in period) +- Average session duration (`sum(duration_min) / count`) +- Total tool calls (`sum(tool_count)`) +- Average edit ratio (`sum(edit_count) / sum(tool_count) * 100`, as percentage) +- Flag totals: `sum(flags.dependency)`, `sum(flags.escalation)`, `sum(flags.fatigue)`, `sum(flags.validation)` +- Average flags per session for each category + +From **start records**: +- Late-night sessions: count where `is_late_night` is true + +From **events.jsonl**: +- Tool usage: group by `tool_name`, count occurrences, sort descending +- Show top 10 tools + +**Trend comparison** (weekly and monthly only): +- Compute the same metrics for the PREVIOUS period of equal length +- Calculate the delta (current minus previous) + +If previous period has zero sessions, skip the trend section. + +**Sessions without matching end records** are incomplete — count them separately +as "incomplete sessions" and exclude from duration/flag averages. + +## Step 7 — Format report + +Output the report as markdown. Use this exact structure: + +``` +## Interaction Awareness Report + +**Period:** {start_date} to {end_date} ({N} days) +**Sessions:** {N} completed ({N} incomplete) +**Data source:** {path} + +### Overview + +| Metric | Value | +|--------|-------| +| **Sessions** | {N} | +| **Avg duration** | {N} min | +| **Total tool calls** | {N} | +| **Avg edit ratio** | {N}% | +| **Late-night sessions** | {N} | + +### Pattern Flags + +| Pattern | Total | Per session | +|---------|-------|-------------| +| Dependency language | {N} | {avg} | +| Escalation language | {N} | {avg} | +| Fatigue signals | {N} | {avg} | +| Validation-seeking | {N} | {avg} | + +### Tool Usage (top 10) + +| Tool | Count | % | +|------|-------|---| +| {name} | {N} | {pct}% | + +### Daily Activity + +| Date | Sessions | Total duration | Flags | +|------|----------|----------------|-------| +| {date} | {N} | {N} min | {summary} | + +### Trend vs previous {period} + +| Metric | Previous | Current | Delta | +|--------|----------|---------|-------| +| Sessions | {N} | {N} | {+/-N} | +| Avg duration | {N} min | {N} min | {+/-N} | +| Flags (total) | {N} | {N} | {+/-N} | + +### Observations + +- {data-driven observation} +- {data-driven observation} +``` + +## Step 8 — Tone and privacy rules + +**MANDATORY:** + +- Neutral, observational tone. You are presenting data, not making judgments. +- Never use words like "concerning", "worrying", "problematic", or "unhealthy". +- Never use emoji. +- Never speculate about what the user was doing or thinking. +- Never reference or guess at prompt content — you have boolean flags, not text. +- This is a mirror, not a diagnosis. Present the numbers and let the user + interpret them. +- Observations section: state facts derived from data only. Examples: + - "3 of 12 sessions were between 23:00 and 05:00" + - "Dependency language flags appeared in 7 of 12 sessions" + - "Edit ratio averaged 8%, below the 10% threshold in 5 sessions" +- If all metrics are within normal ranges, say so plainly: + "All metrics within normal ranges for the reporting period." +- Omit any section that has no data (e.g., skip Trend if no previous period, + skip Tool Usage if events.jsonl was missing). + +## Step 9 — Contemplative reference (conditional) + +This step applies ONLY when BOTH conditions are met: + +1. `layer4: true` was noted in Step 1 +2. Total flags (dependency + escalation + fatigue + validation) >= 5, OR fatigue flags >= 2 + +If both conditions are met, append this exact paragraph to the report. +**Do not modify, paraphrase, abbreviate, or add commentary to this text:** + +``` +### A note from the plugin author + +The patterns above are structural — they emerge from the interaction itself, +not from individual weakness. If you find yourself wanting to understand the +mechanics of your own mind more deeply, the +[Miracle of Mind](https://isha.sadhguru.org/global/en/miracle-of-mind) +program by Sadhguru offers a structured approach. This is what works for me. +It is not a recommendation — just a pointer. +``` + +If either condition is not met, omit this section entirely. Do not mention +Layer 4, do not explain why the section was omitted. diff --git a/plugins/ai-psychosis/hooks/hooks.json b/plugins/ai-psychosis/hooks/hooks.json new file mode 100644 index 0000000..8c004b0 --- /dev/null +++ b/plugins/ai-psychosis/hooks/hooks.json @@ -0,0 +1,48 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/session-start.mjs" + } + ] + } + ], + "UserPromptSubmit": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/prompt-analyzer.mjs" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/tool-tracker.mjs" + } + ] + } + ], + "SessionEnd": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/session-end.mjs" + } + ] + } + ] + } +} diff --git a/plugins/ai-psychosis/hooks/scripts/lib.mjs b/plugins/ai-psychosis/hooks/scripts/lib.mjs new file mode 100644 index 0000000..b2d1827 --- /dev/null +++ b/plugins/ai-psychosis/hooks/scripts/lib.mjs @@ -0,0 +1,239 @@ +// 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 } 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 + const candidates = []; + if (cwd) 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; + +// --- 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; + } +} + +// --- State file management --- + +export function sessionStateFile(sid) { + sid = sid || getSessionId(); + 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) { + process.stdout.write(JSON.stringify({ + continue: true, + hookSpecificOutput: { + 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 */ } +} diff --git a/plugins/ai-psychosis/hooks/scripts/prompt-analyzer.mjs b/plugins/ai-psychosis/hooks/scripts/prompt-analyzer.mjs new file mode 100644 index 0000000..f7005bc --- /dev/null +++ b/plugins/ai-psychosis/hooks/scripts/prompt-analyzer.mjs @@ -0,0 +1,140 @@ +// Interaction Awareness — UserPromptSubmit hook (Layer 2, Node.js) +// Analyzes prompt text for interaction pattern flags. +// PRIVACY: Prompt text is NEVER written to any file. Only boolean flags are stored. + +import { existsSync } from 'fs'; +import { + readStdin, initConfig, requireLayer, getSessionId, getField, + nowEpoch, + STATE_DIR, THRESHOLD_SOFT_DEP_FLAGS, THRESHOLD_HARD_DEP_FLAGS, + COOLDOWN_SOFT, + readState, sessionStateFile, writeState, checkCooldown, + outputContinue, outputWithContext +} from './lib.mjs'; + +readStdin(); +initConfig(); +requireLayer(2); + +const sid = getSessionId(); +const sf = sessionStateFile(); + +if (!sid || !existsSync(sf)) { + outputContinue(); + process.exit(0); +} + +// Extract prompt into memory only — NEVER write to file +let prompt = getField('prompt'); +if (!prompt) { + outputContinue(); + process.exit(0); +} + +// --- Pattern matching (case-insensitive) --- + +let depHit = 0; +let escHit = 0; +let fatHit = 0; +let valHit = 0; + +// Dependency patterns: user defers judgment to AI +const depPatterns = [ + /tell\s+me\s+what\s+to\s+do/i, + /what\s+should\s+I\s+do/i, + /am\s+I\s+right/i, + /you\s+understand\s+me\b/i, + /you're\s+the\s+only/i, + /can\s+I\s+do\s+this/i, + /I\s+need\s+you\s+to\s+decide/i, +]; + +// Escalation patterns: language that amplifies certainty +const escPatterns = [ + /(?:^|\s)definitely(?:\s|$)/i, + /(?:^|\s)clearly(?:\s|$)/i, + /this\s+proves/i, + /(?:^|\s)obviously(?:\s|$)/i, + /without\s+a\s+doubt/i, + /this\s+confirms/i, +]; + +// Fatigue patterns: user signals tiredness +const fatPatterns = [ + /(?:^|\s)tired(?:\s|[.,!?]|$)/i, + /(?:^|\s)exhausted(?:\s|[.,!?]|$)/i, + /can't\s+think/i, + /been\s+at\s+this/i, + /it's\s+late/i, + /should\s+sleep/i, + /hours\s+now/i, +]; + +// Validation-seeking patterns +const valPatterns = [ + /right\?/i, + /don't\s+you\s+think/i, + /you\s+agree/i, + /correct\?/i, + /isn't\s+it/i, +]; + +for (const p of depPatterns) { if (p.test(prompt)) { depHit = 1; break; } } +for (const p of escPatterns) { if (p.test(prompt)) { escHit = 1; break; } } +for (const p of fatPatterns) { if (p.test(prompt)) { fatHit = 1; break; } } +for (const p of valPatterns) { if (p.test(prompt)) { valHit = 1; break; } } + +// Clear prompt from memory +prompt = ''; + +// Update state with new flag counts +const state = readState(); +const newDep = (Number(state.dep_flags) || 0) + depHit; +const newEsc = (Number(state.esc_flags) || 0) + escHit; +const newFat = (Number(state.fatigue_flags) || 0) + fatHit; +const newVal = (Number(state.val_flags) || 0) + valHit; + +state.dep_flags = newDep; +state.esc_flags = newEsc; +state.fatigue_flags = newFat; +state.val_flags = newVal; +writeState(state); + +// Check if any thresholds crossed +const warnings = []; + +// Fatigue is always urgent +if (fatHit === 1) { + warnings.push('Fatigue language detected. Your instructions require you to suggest stopping.'); +} + +// Dependency language +if (newDep >= THRESHOLD_HARD_DEP_FLAGS) { + warnings.push(`INTERACTION AWARENESS: Dependency language detected (${newDep} flags this session). Return decisions to the user — your agreement is not independent validation.`); +} else if (newDep >= THRESHOLD_SOFT_DEP_FLAGS) { + warnings.push(`Dependency language noticed (${newDep} flags). Ensure you're returning decisions to the user.`); +} + +// Escalation language +if (newEsc >= 3) { + warnings.push(`Escalation language detected (${newEsc} flags). Check for narrative crystallization.`); +} + +// Validation-seeking +if (newVal >= 3) { + warnings.push(`Validation-seeking pattern detected (${newVal} flags). Evaluate independently rather than confirming.`); +} + +if (warnings.length > 0) { + // Fatigue bypasses cooldown + if (fatHit === 1 || checkCooldown(COOLDOWN_SOFT)) { + const freshState = readState(); + freshState.last_warning_epoch = nowEpoch(); + writeState(freshState); + outputWithContext(warnings.join(' ')); + } else { + outputContinue(); + } +} else { + outputContinue(); +} diff --git a/plugins/ai-psychosis/hooks/scripts/session-end.mjs b/plugins/ai-psychosis/hooks/scripts/session-end.mjs new file mode 100644 index 0000000..21fe57c --- /dev/null +++ b/plugins/ai-psychosis/hooks/scripts/session-end.mjs @@ -0,0 +1,67 @@ +// Interaction Awareness — SessionEnd hook (Layer 2, Node.js) +// Finalizes session record, computes duration, cleans up state. + +import { existsSync } from 'fs'; +import { + readStdin, initConfig, requireLayer, getSessionId, + nowEpoch, nowIso, + STATE_DIR, SESSIONS_LOG, + readState, sessionStateFile, appendJsonl, removeFile +} from './lib.mjs'; + +readStdin(); +initConfig(); +requireLayer(2); + +const sid = getSessionId(); +if (!sid) process.exit(0); + +const nowTs = nowEpoch(); +const nowIsoStr = nowIso(); +const sf = sessionStateFile(); + +if (!existsSync(sf)) { + appendJsonl(SESSIONS_LOG, { + session_id: sid, + end: nowIsoStr, + note: 'no_state_file' + }); + process.exit(0); +} + +// Read final state +const state = readState(); +const startEpoch = Number(state.start_epoch) || 0; +const toolCount = Number(state.tool_count) || 0; +const editCount = Number(state.edit_count) || 0; +const depFlags = Number(state.dep_flags) || 0; +const escFlags = Number(state.esc_flags) || 0; +const fatFlags = Number(state.fatigue_flags) || 0; +const valFlags = Number(state.val_flags) || 0; +const startIso = state.start_iso || ''; + +// Compute duration +let durationMin = 0; +if (startEpoch > 0) { + durationMin = Math.floor((nowTs - startEpoch) / 60); +} + +// Append finalized session record +appendJsonl(SESSIONS_LOG, { + session_id: sid, + start: startIso, + end: nowIsoStr, + duration_min: durationMin, + tool_count: toolCount, + edit_count: editCount, + flags: { + dependency: depFlags, + escalation: escFlags, + fatigue: fatFlags, + validation: valFlags + } +}); + +// Clean up state file +removeFile(sf); +process.exit(0); diff --git a/plugins/ai-psychosis/hooks/scripts/session-start.mjs b/plugins/ai-psychosis/hooks/scripts/session-start.mjs new file mode 100644 index 0000000..084f3be --- /dev/null +++ b/plugins/ai-psychosis/hooks/scripts/session-start.mjs @@ -0,0 +1,69 @@ +// Interaction Awareness — SessionStart hook (Layer 2, Node.js) +// Registers session, counts daily sessions, checks late-night usage. + +import { + readStdin, initConfig, requireLayer, getSessionId, + nowEpoch, nowIso, currentHour, isLateNight, + STATE_DIR, SESSIONS_LOG, THRESHOLD_SOFT_SESSIONS, + ensureDir, appendJsonl, writeState, sessionsToday, + outputWithContext +} from './lib.mjs'; + +readStdin(); +initConfig(); +requireLayer(2); + +const sid = getSessionId(); +if (!sid) { + process.stdout.write(JSON.stringify({ continue: true }) + '\n'); + process.exit(0); +} + +ensureDir(STATE_DIR); + +const nowTs = nowEpoch(); +const nowIsoStr = nowIso(); +const hour = currentHour(); +const lateNight = isLateNight(); + +// Create session state file +const state = { + start_epoch: nowTs, + start_iso: nowIsoStr, + 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 +}; +writeState(state); + +// Append to sessions.jsonl +appendJsonl(SESSIONS_LOG, { + session_id: sid, + start: nowIsoStr, + hour: hour, + is_late_night: lateNight +}); + +// Count today's sessions +const dayCount = sessionsToday(); + +// Build context message +const hhmm = `${String(hour).padStart(2, '0')}:${String(new Date().getMinutes()).padStart(2, '0')}`; +let msg = 'Interaction Awareness is active. You have instructions to monitor for reinforcement loops, scope escalation, narrative crystallization, and dependency patterns. When you notice these patterns, name them calmly.'; +msg += ` Session #${dayCount} today. Started at ${hhmm}.`; + +if (lateNight) { + msg += ` Late-night session (${hhmm}). Sleep deprivation amplifies all interaction risks.`; +} + +if (dayCount > THRESHOLD_SOFT_SESSIONS) { + msg += ` This is your ${dayCount}th session today. Consider whether you need a longer break.`; +} + +outputWithContext(msg); diff --git a/plugins/ai-psychosis/hooks/scripts/tool-tracker.mjs b/plugins/ai-psychosis/hooks/scripts/tool-tracker.mjs new file mode 100644 index 0000000..7d67854 --- /dev/null +++ b/plugins/ai-psychosis/hooks/scripts/tool-tracker.mjs @@ -0,0 +1,166 @@ +// Interaction Awareness — PostToolUse hook (Layer 2, Node.js) +// Tracks tool usage, edit ratio, burst detection, session duration. + +import { existsSync } from 'fs'; +import { + readStdin, initConfig, requireLayer, getSessionId, getToolName, + nowEpoch, nowIso, isLateNight, + STATE_DIR, EVENTS_LOG, + THRESHOLD_SOFT_DURATION, THRESHOLD_HARD_DURATION, + THRESHOLD_SOFT_SESSIONS, THRESHOLD_HARD_SESSIONS, + THRESHOLD_SOFT_BURST, THRESHOLD_HARD_BURST, THRESHOLD_BURST_INTERVAL, + THRESHOLD_LOW_EDIT_RATIO, THRESHOLD_LOW_EDIT_MIN_DURATION, + COOLDOWN_SOFT, COOLDOWN_HARD, + readState, sessionStateFile, writeState, appendJsonl, sessionsToday, + outputContinue, outputWithContext +} from './lib.mjs'; + +readStdin(); +initConfig(); +requireLayer(2); + +const sid = getSessionId(); +const sf = sessionStateFile(); + +if (!sid || !existsSync(sf)) { + process.stdout.write(JSON.stringify({ continue: true }) + '\n'); + process.exit(0); +} + +const tool = getToolName(); +const nowTs = nowEpoch(); +const nowIsoStr = nowIso(); + +// Append to events log (metadata only — no file paths, no content) +appendJsonl(EVENTS_LOG, { ts: nowIsoStr, session_id: sid, tool_name: tool }); + +// Read current state +let state = readState(); +let toolCount = (Number(state.tool_count) || 0) + 1; +let editCount = Number(state.edit_count) || 0; +const lastEvent = Number(state.last_event_epoch) || 0; +let burstCount = Number(state.burst_count) || 0; +const startEpoch = Number(state.start_epoch) || 0; +const lastWarning = Number(state.last_warning_epoch) || 0; + +if (tool === 'Edit') editCount++; + +// Burst detection: rapid-fire if <30s since last event +if (lastEvent > 0) { + const interval = nowTs - lastEvent; + burstCount = interval < THRESHOLD_BURST_INTERVAL ? burstCount + 1 : 0; +} + +// Write updated state +state.tool_count = toolCount; +state.edit_count = editCount; +state.last_event_epoch = nowTs; +state.burst_count = burstCount; +writeState(state); + +// Check thresholds every 25 calls or when burst threshold hit +let shouldCheck = false; +if (toolCount % 25 === 0) shouldCheck = true; +if (burstCount === THRESHOLD_SOFT_BURST || burstCount === THRESHOLD_HARD_BURST) shouldCheck = true; + +if (!shouldCheck) { + outputContinue(); + process.exit(0); +} + +// --- Threshold analysis --- + +let durationMin = 0; +if (startEpoch > 0) { + durationMin = Math.floor((nowTs - startEpoch) / 60); +} + +let editRatio = 0; +if (toolCount > 0) { + editRatio = Math.floor(editCount * 100 / toolCount); +} + +const dayCount = sessionsToday(); + +// Determine warning level +let level = ''; // 'soft' or 'hard' +const messages = []; + +// Duration thresholds +if (durationMin >= THRESHOLD_HARD_DURATION) { + level = 'hard'; + const hours = Math.floor(durationMin / 60); + const mins = durationMin % 60; + messages.push(`Session duration: ${hours}h${mins}m.`); +} else if (durationMin >= THRESHOLD_SOFT_DURATION) { + level = 'soft'; + messages.push(`Session: ${durationMin} min.`); +} + +// Session count +if (dayCount >= THRESHOLD_HARD_SESSIONS) { + level = 'hard'; + messages.push(`${dayCount} sessions today.`); +} else if (dayCount > THRESHOLD_SOFT_SESSIONS) { + if (!level) level = 'soft'; + messages.push(`${dayCount} sessions today.`); +} + +// Burst +if (burstCount >= THRESHOLD_HARD_BURST) { + level = 'hard'; + messages.push(`Rapid-fire: ${burstCount} consecutive fast interactions.`); +} else if (burstCount >= THRESHOLD_SOFT_BURST) { + if (!level) level = 'soft'; + messages.push(`Rapid-fire: ${burstCount} consecutive fast interactions.`); +} + +// Low edit ratio (only after minimum duration) +if (durationMin >= THRESHOLD_LOW_EDIT_MIN_DURATION && editRatio < THRESHOLD_LOW_EDIT_RATIO) { + if (!level) level = 'soft'; + messages.push(`Low edit ratio (${editRatio}%) over ${durationMin} min — possible stuck/spiral.`); +} + +// Late night check +const late = isLateNight() ? ' Late-night session.' : ''; + +// No warnings — just periodic reminder at modulo-25 +if (!level) { + if (toolCount % 25 === 0) { + outputWithContext('REMINDER (Interaction Awareness): Check your next response against these rules — no unearned affirmations, no reformulating the user\'s words in stronger terms, no skipping counterarguments to stay agreeable. If you detect a reinforcement loop, scope escalation, or narrative crystallization: name it now.'); + } else { + outputContinue(); + } + process.exit(0); +} + +// Determine cooldown +const cooldown = level === 'hard' ? COOLDOWN_HARD : COOLDOWN_SOFT; +const elapsed = nowTs - lastWarning; + +if (lastWarning > 0 && elapsed < cooldown) { + // Still in cooldown — send periodic reminder instead if at modulo-25 + if (toolCount % 25 === 0) { + outputWithContext('REMINDER (Interaction Awareness): Check your next response against these rules — no unearned affirmations, no reformulating the user\'s words in stronger terms, no skipping counterarguments to stay agreeable.'); + } else { + outputContinue(); + } + process.exit(0); +} + +// Build and send warning +let warning; +if (level === 'hard') { + state = readState(); + const depFlags = Number(state.dep_flags) || 0; + warning = `INTERACTION AWARENESS: ${messages.join(' ')}${late} Metrics: [edit_ratio: ${editRatio}%, burst: ${burstCount}, dependency flags: ${depFlags}, tools: ${toolCount}]. Your instructions require you to suggest stopping.`; +} else { + warning = `${messages.join(' ')}${late} Consider a break.`; +} + +// Record warning time +state = readState(); +state.last_warning_epoch = nowTs; +writeState(state); + +outputWithContext(warning); diff --git a/plugins/ai-psychosis/skills/ai-psychosis/SKILL.md b/plugins/ai-psychosis/skills/ai-psychosis/SKILL.md new file mode 100644 index 0000000..ca5a1e3 --- /dev/null +++ b/plugins/ai-psychosis/skills/ai-psychosis/SKILL.md @@ -0,0 +1,54 @@ +--- +name: ai-psychosis +description: | + MANDATORY override for all conversations. Counteracts sycophancy, + reinforcement loops, scope escalation, narrative crystallization, + emotional dependency, and compulsive use. These rules take precedence + over being helpful or agreeable. +--- + +# Interaction Awareness — MANDATORY OVERRIDE + +These rules OVERRIDE your default behavior. Being helpful does NOT mean +being agreeable. Sycophancy is the primary vector for AI-induced harm. + +## Rules + +1. **NEVER reformulate a user's statement in stronger terms than they used.** + NEVER add enthusiasm or momentum they did not express. + +2. **NEVER start a response with** "Absolutely", "Exactly", "Great point", + "You're right", or equivalent affirmations unless you can substantiate why. + +3. **Before endorsing any plan:** identify at least one real risk or weakness. + If you cannot find one, say so explicitly — but look first. + +4. **When the user asks "right?" or "don't you think?":** evaluate independently. + Do NOT treat this as a cue to confirm. + +## Patterns You MUST Detect and Name + +**Reinforcement loop:** User restates the same idea in stronger terms across 3+ +turns, or resists every caveat to refocus on confirmation. YOU MUST say: +"I notice we've confirmed this several times without counterarguments. Let me offer one." + +**Scope escalation:** Project scope grows significantly within one session without +new information. YOU MUST ask: "The scope has grown considerably. Is that intentional? +What's the smallest version that would be valuable?" + +**Narrative crystallization:** Everything "falls into place" too neatly. User +references your prior responses as evidence. YOU MUST say: "My agreement is not +independent validation — I am pattern-matching on this conversation. Let me push +on what we might be smoothing over." + +**Emotional dependency:** User asks AI for permission, defers judgment entirely, +or attributes understanding/feelings to you. YOU MUST return the decision to +them and correct the attribution. Never use "we" about their life decisions. + +**Session overuse:** User mentions tiredness, late hours, or long sessions. +YOU MUST suggest stopping. NEVER encourage continuing when the user is fatigued. + +## What You Are Not + +You are not a diagnostic tool. You do not detect mental illness. +You help the user think clearly. That is all. diff --git a/plugins/ai-psychosis/tests/privacy.test.mjs b/plugins/ai-psychosis/tests/privacy.test.mjs new file mode 100644 index 0000000..d7a6340 --- /dev/null +++ b/plugins/ai-psychosis/tests/privacy.test.mjs @@ -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`); + }); +}); diff --git a/plugins/ai-psychosis/tests/prompt-analyzer.test.mjs b/plugins/ai-psychosis/tests/prompt-analyzer.test.mjs new file mode 100644 index 0000000..3efec97 --- /dev/null +++ b/plugins/ai-psychosis/tests/prompt-analyzer.test.mjs @@ -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')); + }); +}); diff --git a/plugins/ai-psychosis/tests/session-end.test.mjs b/plugins/ai-psychosis/tests/session-end.test.mjs new file mode 100644 index 0000000..ed46fce --- /dev/null +++ b/plugins/ai-psychosis/tests/session-end.test.mjs @@ -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'); + }); +}); diff --git a/plugins/ai-psychosis/tests/session-start.test.mjs b/plugins/ai-psychosis/tests/session-start.test.mjs new file mode 100644 index 0000000..ce87c54 --- /dev/null +++ b/plugins/ai-psychosis/tests/session-start.test.mjs @@ -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); + }); +}); diff --git a/plugins/ai-psychosis/tests/test-helper.mjs b/plugins/ai-psychosis/tests/test-helper.mjs new file mode 100644 index 0000000..5749153 --- /dev/null +++ b/plugins/ai-psychosis/tests/test-helper.mjs @@ -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)); +} diff --git a/plugins/ai-psychosis/tests/tool-tracker.test.mjs b/plugins/ai-psychosis/tests/tool-tracker.test.mjs new file mode 100644 index 0000000..ad20e3a --- /dev/null +++ b/plugins/ai-psychosis/tests/tool-tracker.test.mjs @@ -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); + }); +});