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 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-04-08 08:58:35 +02:00
commit aca38d6558
20 changed files with 2299 additions and 0 deletions

View file

@ -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"
}

18
.gitignore vendored Normal file
View file

@ -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

130
CHANGELOG.md Normal file
View file

@ -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

85
CLAUDE.md Normal file
View file

@ -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 `<project>/.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

21
LICENSE Normal file
View file

@ -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.

379
README.md Normal file
View file

@ -0,0 +1,379 @@
<!-- badges -->
![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:0005: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:0005: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 `<project>/.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)

View file

@ -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.

48
hooks/hooks.json Normal file
View file

@ -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"
}
]
}
]
}
}

239
hooks/scripts/lib.mjs Normal file
View file

@ -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 */ }
}

View file

@ -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();
}

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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.

44
tests/privacy.test.mjs Normal file
View file

@ -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`);
});
});

View file

@ -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'));
});
});

View file

@ -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');
});
});

View file

@ -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);
});
});

53
tests/test-helper.mjs Normal file
View file

@ -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));
}

View file

@ -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);
});
});