feat: add ai-psychosis plugin to open marketplace
Meta-awareness tools for healthy AI interaction patterns. Detects reinforcement loops, scope escalation, and compulsive patterns. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f93d6abdae
commit
297867f847
19 changed files with 1920 additions and 0 deletions
8
plugins/ai-psychosis/.claude-plugin/plugin.json
Normal file
8
plugins/ai-psychosis/.claude-plugin/plugin.json
Normal 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": "Kjell Tore Guttormsen",
|
||||
"license": "MIT",
|
||||
"repository": "https://git.fromaitochitta.com/open/ai-psychosis"
|
||||
}
|
||||
18
plugins/ai-psychosis/.gitignore
vendored
Normal file
18
plugins/ai-psychosis/.gitignore
vendored
Normal 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
plugins/ai-psychosis/CHANGELOG.md
Normal file
130
plugins/ai-psychosis/CHANGELOG.md
Normal 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
plugins/ai-psychosis/CLAUDE.md
Normal file
85
plugins/ai-psychosis/CLAUDE.md
Normal 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
plugins/ai-psychosis/LICENSE
Normal file
21
plugins/ai-psychosis/LICENSE
Normal 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.
|
||||
256
plugins/ai-psychosis/commands/interaction-report.md
Normal file
256
plugins/ai-psychosis/commands/interaction-report.md
Normal 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
plugins/ai-psychosis/hooks/hooks.json
Normal file
48
plugins/ai-psychosis/hooks/hooks.json
Normal 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
plugins/ai-psychosis/hooks/scripts/lib.mjs
Normal file
239
plugins/ai-psychosis/hooks/scripts/lib.mjs
Normal 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 */ }
|
||||
}
|
||||
140
plugins/ai-psychosis/hooks/scripts/prompt-analyzer.mjs
Normal file
140
plugins/ai-psychosis/hooks/scripts/prompt-analyzer.mjs
Normal 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();
|
||||
}
|
||||
67
plugins/ai-psychosis/hooks/scripts/session-end.mjs
Normal file
67
plugins/ai-psychosis/hooks/scripts/session-end.mjs
Normal 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);
|
||||
69
plugins/ai-psychosis/hooks/scripts/session-start.mjs
Normal file
69
plugins/ai-psychosis/hooks/scripts/session-start.mjs
Normal 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);
|
||||
166
plugins/ai-psychosis/hooks/scripts/tool-tracker.mjs
Normal file
166
plugins/ai-psychosis/hooks/scripts/tool-tracker.mjs
Normal 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);
|
||||
54
plugins/ai-psychosis/skills/ai-psychosis/SKILL.md
Normal file
54
plugins/ai-psychosis/skills/ai-psychosis/SKILL.md
Normal 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
plugins/ai-psychosis/tests/privacy.test.mjs
Normal file
44
plugins/ai-psychosis/tests/privacy.test.mjs
Normal 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`);
|
||||
});
|
||||
});
|
||||
313
plugins/ai-psychosis/tests/prompt-analyzer.test.mjs
Normal file
313
plugins/ai-psychosis/tests/prompt-analyzer.test.mjs
Normal 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'));
|
||||
});
|
||||
});
|
||||
66
plugins/ai-psychosis/tests/session-end.test.mjs
Normal file
66
plugins/ai-psychosis/tests/session-end.test.mjs
Normal 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');
|
||||
});
|
||||
});
|
||||
49
plugins/ai-psychosis/tests/session-start.test.mjs
Normal file
49
plugins/ai-psychosis/tests/session-start.test.mjs
Normal 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
plugins/ai-psychosis/tests/test-helper.mjs
Normal file
53
plugins/ai-psychosis/tests/test-helper.mjs
Normal 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));
|
||||
}
|
||||
94
plugins/ai-psychosis/tests/tool-tracker.test.mjs
Normal file
94
plugins/ai-psychosis/tests/tool-tracker.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue