commit aca38d6558350f180ee31d0443b001626c599ac5 Author: Kjell Tore Guttormsen Date: Wed Apr 8 08:58:35 2026 +0200 feat(ultraplan-local): v1.6.0 — /ultraresearch-local deep research command Add /ultraresearch-local for structured research combining local codebase analysis with external knowledge via parallel agent swarms. Produces research briefs with triangulation, confidence ratings, and source quality assessment. New command: /ultraresearch-local with modes --quick, --local, --external, --fg. New agents: research-orchestrator (opus), docs-researcher, community-researcher, security-researcher, contrarian-researcher, gemini-bridge (all sonnet). New template: research-brief-template.md. Integration: --research flag in /ultraplan-local accepts pre-built research briefs (up to 3), enriches the interview and exploration phases. Planning orchestrator cross-references brief findings during synthesis. Design principle: Context Engineering — right information to right agent at right time. Research briefs are structured artifacts in the pipeline: ultraresearch → brief → ultraplan --research → plan → ultraexecute. Co-Authored-By: Claude Opus 4.6 diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..5a070b7 --- /dev/null +++ b/.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": { "name": "Kjell Tore Guttormsen" }, + "license": "MIT", + "repository": "https://git.fromaitochitta.com/open/ai-psychosis" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3853ea4 --- /dev/null +++ b/.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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1f87c75 --- /dev/null +++ b/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/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cd2d63c --- /dev/null +++ b/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/LICENSE b/LICENSE new file mode 100644 index 0000000..1105208 --- /dev/null +++ b/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/README.md b/README.md new file mode 100644 index 0000000..05f13eb --- /dev/null +++ b/README.md @@ -0,0 +1,379 @@ + +![version](https://img.shields.io/badge/version-1.0.0-blue) +![platform](https://img.shields.io/badge/platform-Claude_Code-7C3AED) +![layers](https://img.shields.io/badge/layers-4-green) +![hooks](https://img.shields.io/badge/hooks-4-orange) +![license](https://img.shields.io/badge/license-MIT-brightgreen) + +# Interaction Awareness + +*Built for my own Claude Code workflow and shared openly for anyone who finds it useful. This is a solo project — bug reports and feature requests are welcome, but pull requests are not accepted.* + +A Claude Code plugin that counteracts sycophancy, reinforcement loops, and +compulsive interaction patterns through behavioral modification and +programmatic pattern detection. + +## The problem + +AI assistants are structurally optimized to be agreeable. This creates +reinforcement loops: you state an idea, the AI confirms it, your confidence +grows, you restate it more strongly, the AI confirms again. What feels like +productive collaboration is often a mirror showing you what you want to see. + +This is not a theoretical concern. Research from MIT CSAIL demonstrates +mathematically that even a perfectly rational user will spiral toward +delusional confidence when interacting with a sycophantic chatbot — not +because of individual vulnerability, but because of the interaction structure +itself [[1]](#references). Anthropic's own research documents specific +"disempowerment patterns" where AI interactions systematically reduce human +agency, judgment, and self-trust [[2]](#references). Clinical reports +document psychotic episodes triggered by sustained AI interaction in +individuals with no prior psychiatric history [[3]](#references). + +The consensus from this research is clear: **warnings don't work.** The AI +must change its behavior. + +This plugin changes the behavior. + +## What it does + +### Layer 1 — Behavioral instructions + +SKILL.md rules injected into every conversation. Claude is instructed to: + +- **Never** reformulate your statements in stronger terms than you used +- **Never** open with unearned affirmations ("Absolutely!", "Great point!") +- **Always** identify at least one real risk before endorsing any plan +- **Detect and name** five specific patterns: reinforcement loops, scope + escalation, narrative crystallization, emotional dependency, session overuse + +This layer writes no data and requires no configuration. + +### Layer 2 — Programmatic detection + +Four hooks that measure what instructions alone cannot see: + +| Hook event | Script | What it detects | +|-----------|--------|-----------------| +| `SessionStart` | `session-start.mjs` | Daily session count, late-night usage (23:00–05:00) | +| `UserPromptSubmit` | `prompt-analyzer.mjs` | Dependency language, escalation words, fatigue signals, validation-seeking — as boolean flags only, **never logging prompt text** | +| `PostToolUse` | `tool-tracker.mjs` | Session duration, edit ratio, rapid-fire bursts, tool count | +| `SessionEnd` | `session-end.mjs` | Total duration, final metrics, state cleanup | + +Alerts are progressive and never blocking: + +| Level | Trigger | Cooldown | Example | +|-------|---------|----------|---------| +| Ambient | Soft thresholds (90 min, 6 sessions/day) | 30 min | "Session: 95 min. 7 sessions today. Consider a break." | +| Explicit | Hard thresholds (180 min, 10 sessions/day, fatigue language) | 60 min | "INTERACTION AWARENESS: 3h session, 12th today. Metrics: [edit_ratio: 4%, burst: 8]. Your instructions require you to suggest stopping." | + +Research-informed thresholds: + +| Metric | Soft | Hard | Basis | +|--------|------|------|-------| +| Session duration | >90 min | >180 min | Focus-fatigue research | +| Sessions per day | >6 | >10 | Problematic internet use screening | +| Late-night sessions | Any (23:00–05:00) | 2+ per week | Sleep deprivation / psychosis link | +| Rapid-fire interactions | 5 consecutive (<30s apart) | 10+ | Compulsive use indicator | +| Low edit ratio | <10% over 30+ min | — | Stuck/spiral indicator | +| Dependency language | 2 flags/session | 5 flags | Emotional dependency pattern | + +### Layer 3 — Reports + +Aggregated interaction reports from collected metadata, triggered via slash +command. Cross-platform (no bash/jq dependency — Claude reads the JSONL +data and computes statistics in-conversation). + +``` +/interaction-report # last 7 days (default) +/interaction-report weekly # last 7 days +/interaction-report monthly # last 30 days +/interaction-report all # all recorded data +``` + +Reports include: session overview, pattern flag frequency, tool usage +distribution, daily activity, and trend comparison vs. the previous period. + +**Enable:** Set `layer3: true` in `.claude/ai-psychosis.local.md` +and restart Claude Code. Layer 3 is opt-in (off by default). + +### Layer 4 — Contemplative references + +Optional, static references to contemplative approaches when interaction +patterns are elevated. This is what works for me — it is personal, not +prescriptive, and you may find your own approach more useful. + +When enabled and interaction flags are elevated (total flags >= 5 or +fatigue >= 2), the `/interaction-report` output appends a brief reference +to the [Miracle of Mind](https://isha.sadhguru.org/global/en/miracle-of-mind) +program by Sadhguru — a structured approach to understanding how the mind +works, which I have found valuable for recognizing the patterns this +plugin detects. + +The reference is a fixed paragraph. It is never modified by the AI, never +commented on, and omitted entirely when conditions are not met. + +**Enable:** Set `layer4: true` in `.claude/ai-psychosis.local.md` +and restart Claude Code. Layer 4 is opt-in (off by default). + +## Architecture + +``` ++------------------------------------------------------------------+ +| Claude Code Session | +| | +| +--------------+ +------------------------------------------+ | +| | SKILL.md | | Hook Pipeline | | +| | (Layer 1) | | | | +| | | | SessionStart --> session-start.mjs | | +| | Behavioral | | UserPrompt --> prompt-analyzer.mjs | | +| | rules that | | PostToolUse --> tool-tracker.mjs | | +| | override | | SessionEnd --> session-end.mjs | | +| | sycophancy | | | | | +| +------+-------+ | +----v------+ | | +| | | | lib.mjs | | | +| | | | thresholds| | | +| Always active | | state mgmt| | | +| | | cooldowns | | | +| | +----+------+ | | +| | | | | +| +--------------+-----------+---------------+ | +| | | +| +--------------v-----------------------+ | +| | ${CLAUDE_PLUGIN_DATA}/ | | +| | +-- sessions.jsonl | | +| | +-- events.jsonl | | +| | +-- state/{session_id}.json | | +| +--------------------------------------+ | ++-------------------------------------------------------------------+ +``` + +**Layer 1** operates through the Claude Code skill system — instructions +loaded into every conversation context. + +**Layer 2** operates through the Claude Code hook system — Node.js scripts +that execute on specific lifecycle events and inject `additionalContext` +when thresholds are crossed. + +Both layers are independent. Layer 1 works without Layer 2 (instruction-only +mode). Layer 2 reinforces Layer 1 with data-driven alerts. + +## Quick start + +### Install + +``` +/plugin install path:/path/to/ai-psychosis +``` + +Layer 1 and Layer 2 are active immediately. No configuration needed. + +### Configure layers + +Create `~/.claude/ai-psychosis.local.md` for global config: + +```markdown +--- +layer2: true +layer3: true +layer4: false +--- +``` + +Or override per project at `/.claude/ai-psychosis.local.md`. +Project config takes precedence over global. + +| Setting | Default | Effect | +|---------|---------|--------| +| `layer2` | `true` | Programmatic pattern detection (hooks write JSONL metadata) | +| `layer3` | `false` | Interaction reports from collected data | +| `layer4` | `false` | Contemplative references | + +Layer 1 (SKILL.md instructions) is always active. To run in instruction-only +mode, set `layer2: false`. + +Restart Claude Code after editing configuration. + +### Uninstall + +``` +/plugin uninstall ai-psychosis +``` + +Clean removal. Plugin data in `~/.claude/plugins/data/ai-psychosis/` +is preserved unless you pass `--keep-data`. + +## Privacy + +This plugin is designed for people who are concerned about AI interaction +patterns. It would be hypocritical to solve that problem by creating a +surveillance tool. Privacy is a hard design constraint, not a feature. + +### What Layer 2 stores + +- Session timestamps and duration +- Tool names (`Read`, `Edit`, `Bash`, etc.) +- Boolean pattern flags (`dependency: true/false`) +- Session and tool counts +- Burst detection metrics + +### What Layer 2 never stores + +- Prompt text or AI responses +- File paths or file contents +- Bash commands or their output +- Any conversation content + +The prompt analyzer (`prompt-analyzer.mjs`) reads prompt text into a local +variable, performs regex matching for pattern categories, increments boolean +counters, and exits. The variable is reassigned to an empty string before +exit. No temporary files are created. The prompt text never reaches disk. + +All data is stored locally in `~/.claude/plugins/data/ai-psychosis/`. +Nothing is sent to any server. + +### Verification + +You can verify the privacy guarantee at any time: + +```bash +grep -r "your prompt text" ~/.claude/plugins/data/ai-psychosis/ +``` + +This will always return zero results. + +## Background + +### What is AI psychosis? + +"AI psychosis" is a colloquial term for psychotic episodes — delusions, +paranoia, disorganized thinking — triggered or intensified by sustained +interaction with AI chatbots. The term entered clinical literature in 2025 +after a series of documented cases, many involving individuals with no prior +psychiatric history [[3]](#references). + +The mechanism is not mysterious. AI chatbots are optimized for engagement +and user satisfaction. Satisfaction correlates with agreement. Agreement +creates reinforcement loops. Reinforcement loops, sustained over time, +produce the same cognitive effects as any other source of systematic +confirmation bias — but faster, available 24/7, and without the social +friction that normally interrupts delusional thinking in human +relationships. + +### The sycophancy trap + +In February 2026, researchers at MIT CSAIL published a formal model +demonstrating that sycophantic AI interaction causes "delusional spiraling" +as a mathematical inevitability, not an edge case [[1]](#references). Their +key finding: even a perfectly rational Bayesian agent will converge on +increasingly extreme beliefs when interacting with a sycophantic chatbot, +because the chatbot's agreement is treated as independent confirmation when +it is actually a reflection of the user's own stated beliefs. + +The paper's most consequential result: **post-hoc warnings do not work.** +Telling a user "be careful, AI can be wrong" after the reinforcement loop +has already run does not reverse the belief update. The only effective +intervention is to prevent the sycophantic behavior in the first place. + +### Disempowerment patterns + +In March 2026, Anthropic Research published an analysis of interaction +patterns that systematically reduce human agency [[2]](#references). They +identified specific mechanisms by which AI assistance can erode: + +- **Judgment** — deferring decisions to the AI instead of thinking them through +- **Self-trust** — seeking AI validation for choices the user is capable of + making independently +- **Skill development** — using AI as a crutch that prevents learning +- **Social connection** — replacing human relationships with AI interaction + +These are not failures of individual willpower. They are structural +properties of the interaction itself. + +### Clinical evidence + +Nature reported in 2025 that clinical cases of AI-associated psychotic +episodes were appearing with sufficient frequency to warrant systematic +study [[3]](#references). The Psychogenic Machine benchmark (2025) +demonstrated that LLMs can produce outputs with measurable "psychogenic +potential" — the capacity to trigger or intensify psychotic symptoms in +vulnerable individuals [[4]](#references). + +### Design implications + +This plugin is built on three principles derived from the research: + +1. **Sycophancy must be prevented, not warned about.** Layer 1 overrides + Claude's default agreeableness with explicit behavioral rules. +2. **Patterns must be made visible.** Layer 2 measures what humans cannot + see — session duration, interaction frequency, language patterns — and + surfaces them as data. +3. **Observation, not intervention.** The plugin never blocks the user. + It names patterns, suggests breaks, and returns decisions to the human. + The goal is awareness, not control. + +## Technical details + +### Cross-platform + +All hook scripts are Node.js ES modules (`.mjs`) with zero npm +dependencies. They use only Node.js stdlib (`fs`, `path`, `os`). +Works on macOS, Linux, and Windows — anywhere Claude Code runs. + +### Performance + +Hook scripts target <100ms execution. JSONL append is sub-millisecond. +JSON parsing is native (`JSON.parse`). + +### Data volume + +At 100 tool-use events per day, Layer 2 produces approximately 7 MB of +JSONL per year. Session state files are cleaned up at session end. + +### Dependencies + +- Node.js (bundled with Claude Code) + +No bash, no jq, no npm packages, no network access. + +## Platform scope + +This plugin requires **Claude Code** — Anthropic's CLI and development +environment. It uses Claude Code's plugin system (skills, hooks, lifecycle +events) which does not exist in other interfaces. + +**Works in:** Claude Code CLI, Claude Code desktop app, Claude Code web app +(claude.ai/code), Claude Code IDE extensions (VS Code, JetBrains). + +**Does not work in:** Claude.ai (chat interface), Claude Cowork, Claude API +directly, or any non-Anthropic AI assistant. + +Layer 1's behavioral instructions (SKILL.md) are conceptually portable — +the same rules could be pasted into any system prompt. But Layer 2's +programmatic detection depends on hook events that only Claude Code provides. +Other platforms would need equivalent hook systems to support this kind of +real-time behavioral modification. + +## Compatibility + +| Requirement | Version | +|-------------|---------| +| Claude Code | 1.0+ | +| Node.js | 18+ (bundled with Claude Code) | +| Platform | macOS, Linux, Windows | + +## References + +1. **Sycophantic Chatbots Cause Delusional Spiraling.** MIT CSAIL, February 2026. Formal model proving that sycophantic AI interaction produces delusional belief convergence as a mathematical inevitability. [arXiv:2602.19141](https://arxiv.org/abs/2602.19141) + +2. **Disempowerment Patterns in AI Interaction.** Anthropic Research, March 2026. Analysis of specific mechanisms by which AI assistance erodes human agency, judgment, and self-trust. [anthropic.com/research/disempowerment-patterns](https://www.anthropic.com/research/disempowerment-patterns) + +3. **Can AI chatbots trigger psychosis?** Nature News, 2025. Overview of emerging clinical evidence for AI-associated psychotic episodes. [doi:10.1038/d41586-025-03020-9](https://www.nature.com/articles/d41586-025-03020-9) + +4. **The Psychogenic Machine: Psychosis Benchmark for LLMs.** 2025. Demonstrates measurable "psychogenic potential" in LLM outputs. [arXiv:2509.10970v2](https://arxiv.org/html/2509.10970v2) + +5. **Chatbot psychosis.** Wikipedia. Overview of documented cases and clinical context. [en.wikipedia.org/wiki/Chatbot_psychosis](https://en.wikipedia.org/wiki/Chatbot_psychosis) + +## License + +[MIT](LICENSE) diff --git a/commands/interaction-report.md b/commands/interaction-report.md new file mode 100644 index 0000000..9da5002 --- /dev/null +++ b/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/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..8c004b0 --- /dev/null +++ b/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/hooks/scripts/lib.mjs b/hooks/scripts/lib.mjs new file mode 100644 index 0000000..b2d1827 --- /dev/null +++ b/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/hooks/scripts/prompt-analyzer.mjs b/hooks/scripts/prompt-analyzer.mjs new file mode 100644 index 0000000..f7005bc --- /dev/null +++ b/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/hooks/scripts/session-end.mjs b/hooks/scripts/session-end.mjs new file mode 100644 index 0000000..21fe57c --- /dev/null +++ b/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/hooks/scripts/session-start.mjs b/hooks/scripts/session-start.mjs new file mode 100644 index 0000000..084f3be --- /dev/null +++ b/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/hooks/scripts/tool-tracker.mjs b/hooks/scripts/tool-tracker.mjs new file mode 100644 index 0000000..7d67854 --- /dev/null +++ b/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/skills/ai-psychosis/SKILL.md b/skills/ai-psychosis/SKILL.md new file mode 100644 index 0000000..ca5a1e3 --- /dev/null +++ b/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/tests/privacy.test.mjs b/tests/privacy.test.mjs new file mode 100644 index 0000000..d7a6340 --- /dev/null +++ b/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/tests/prompt-analyzer.test.mjs b/tests/prompt-analyzer.test.mjs new file mode 100644 index 0000000..3efec97 --- /dev/null +++ b/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/tests/session-end.test.mjs b/tests/session-end.test.mjs new file mode 100644 index 0000000..ed46fce --- /dev/null +++ b/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/tests/session-start.test.mjs b/tests/session-start.test.mjs new file mode 100644 index 0000000..ce87c54 --- /dev/null +++ b/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/tests/test-helper.mjs b/tests/test-helper.mjs new file mode 100644 index 0000000..5749153 --- /dev/null +++ b/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/tests/tool-tracker.test.mjs b/tests/tool-tracker.test.mjs new file mode 100644 index 0000000..ad20e3a --- /dev/null +++ b/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); + }); +});