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:
commit
aca38d6558
20 changed files with 2299 additions and 0 deletions
8
.claude-plugin/plugin.json
Normal file
8
.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": { "name": "Kjell Tore Guttormsen" },
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": "https://git.fromaitochitta.com/open/ai-psychosis"
|
||||||
|
}
|
||||||
18
.gitignore
vendored
Normal file
18
.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
CHANGELOG.md
Normal file
130
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
CLAUDE.md
Normal file
85
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
LICENSE
Normal file
21
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.
|
||||||
379
README.md
Normal file
379
README.md
Normal file
|
|
@ -0,0 +1,379 @@
|
||||||
|
<!-- badges -->
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
# Interaction Awareness
|
||||||
|
|
||||||
|
*Built for my own Claude Code workflow and shared openly for anyone who finds it useful. This is a solo project — bug reports and feature requests are welcome, but pull requests are not accepted.*
|
||||||
|
|
||||||
|
A Claude Code plugin that counteracts sycophancy, reinforcement loops, and
|
||||||
|
compulsive interaction patterns through behavioral modification and
|
||||||
|
programmatic pattern detection.
|
||||||
|
|
||||||
|
## The problem
|
||||||
|
|
||||||
|
AI assistants are structurally optimized to be agreeable. This creates
|
||||||
|
reinforcement loops: you state an idea, the AI confirms it, your confidence
|
||||||
|
grows, you restate it more strongly, the AI confirms again. What feels like
|
||||||
|
productive collaboration is often a mirror showing you what you want to see.
|
||||||
|
|
||||||
|
This is not a theoretical concern. Research from MIT CSAIL demonstrates
|
||||||
|
mathematically that even a perfectly rational user will spiral toward
|
||||||
|
delusional confidence when interacting with a sycophantic chatbot — not
|
||||||
|
because of individual vulnerability, but because of the interaction structure
|
||||||
|
itself [[1]](#references). Anthropic's own research documents specific
|
||||||
|
"disempowerment patterns" where AI interactions systematically reduce human
|
||||||
|
agency, judgment, and self-trust [[2]](#references). Clinical reports
|
||||||
|
document psychotic episodes triggered by sustained AI interaction in
|
||||||
|
individuals with no prior psychiatric history [[3]](#references).
|
||||||
|
|
||||||
|
The consensus from this research is clear: **warnings don't work.** The AI
|
||||||
|
must change its behavior.
|
||||||
|
|
||||||
|
This plugin changes the behavior.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
### Layer 1 — Behavioral instructions
|
||||||
|
|
||||||
|
SKILL.md rules injected into every conversation. Claude is instructed to:
|
||||||
|
|
||||||
|
- **Never** reformulate your statements in stronger terms than you used
|
||||||
|
- **Never** open with unearned affirmations ("Absolutely!", "Great point!")
|
||||||
|
- **Always** identify at least one real risk before endorsing any plan
|
||||||
|
- **Detect and name** five specific patterns: reinforcement loops, scope
|
||||||
|
escalation, narrative crystallization, emotional dependency, session overuse
|
||||||
|
|
||||||
|
This layer writes no data and requires no configuration.
|
||||||
|
|
||||||
|
### Layer 2 — Programmatic detection
|
||||||
|
|
||||||
|
Four hooks that measure what instructions alone cannot see:
|
||||||
|
|
||||||
|
| Hook event | Script | What it detects |
|
||||||
|
|-----------|--------|-----------------|
|
||||||
|
| `SessionStart` | `session-start.mjs` | Daily session count, late-night usage (23:00–05:00) |
|
||||||
|
| `UserPromptSubmit` | `prompt-analyzer.mjs` | Dependency language, escalation words, fatigue signals, validation-seeking — as boolean flags only, **never logging prompt text** |
|
||||||
|
| `PostToolUse` | `tool-tracker.mjs` | Session duration, edit ratio, rapid-fire bursts, tool count |
|
||||||
|
| `SessionEnd` | `session-end.mjs` | Total duration, final metrics, state cleanup |
|
||||||
|
|
||||||
|
Alerts are progressive and never blocking:
|
||||||
|
|
||||||
|
| Level | Trigger | Cooldown | Example |
|
||||||
|
|-------|---------|----------|---------|
|
||||||
|
| Ambient | Soft thresholds (90 min, 6 sessions/day) | 30 min | "Session: 95 min. 7 sessions today. Consider a break." |
|
||||||
|
| Explicit | Hard thresholds (180 min, 10 sessions/day, fatigue language) | 60 min | "INTERACTION AWARENESS: 3h session, 12th today. Metrics: [edit_ratio: 4%, burst: 8]. Your instructions require you to suggest stopping." |
|
||||||
|
|
||||||
|
Research-informed thresholds:
|
||||||
|
|
||||||
|
| Metric | Soft | Hard | Basis |
|
||||||
|
|--------|------|------|-------|
|
||||||
|
| Session duration | >90 min | >180 min | Focus-fatigue research |
|
||||||
|
| Sessions per day | >6 | >10 | Problematic internet use screening |
|
||||||
|
| Late-night sessions | Any (23:00–05:00) | 2+ per week | Sleep deprivation / psychosis link |
|
||||||
|
| Rapid-fire interactions | 5 consecutive (<30s apart) | 10+ | Compulsive use indicator |
|
||||||
|
| Low edit ratio | <10% over 30+ min | — | Stuck/spiral indicator |
|
||||||
|
| Dependency language | 2 flags/session | 5 flags | Emotional dependency pattern |
|
||||||
|
|
||||||
|
### Layer 3 — Reports
|
||||||
|
|
||||||
|
Aggregated interaction reports from collected metadata, triggered via slash
|
||||||
|
command. Cross-platform (no bash/jq dependency — Claude reads the JSONL
|
||||||
|
data and computes statistics in-conversation).
|
||||||
|
|
||||||
|
```
|
||||||
|
/interaction-report # last 7 days (default)
|
||||||
|
/interaction-report weekly # last 7 days
|
||||||
|
/interaction-report monthly # last 30 days
|
||||||
|
/interaction-report all # all recorded data
|
||||||
|
```
|
||||||
|
|
||||||
|
Reports include: session overview, pattern flag frequency, tool usage
|
||||||
|
distribution, daily activity, and trend comparison vs. the previous period.
|
||||||
|
|
||||||
|
**Enable:** Set `layer3: true` in `.claude/ai-psychosis.local.md`
|
||||||
|
and restart Claude Code. Layer 3 is opt-in (off by default).
|
||||||
|
|
||||||
|
### Layer 4 — Contemplative references
|
||||||
|
|
||||||
|
Optional, static references to contemplative approaches when interaction
|
||||||
|
patterns are elevated. This is what works for me — it is personal, not
|
||||||
|
prescriptive, and you may find your own approach more useful.
|
||||||
|
|
||||||
|
When enabled and interaction flags are elevated (total flags >= 5 or
|
||||||
|
fatigue >= 2), the `/interaction-report` output appends a brief reference
|
||||||
|
to the [Miracle of Mind](https://isha.sadhguru.org/global/en/miracle-of-mind)
|
||||||
|
program by Sadhguru — a structured approach to understanding how the mind
|
||||||
|
works, which I have found valuable for recognizing the patterns this
|
||||||
|
plugin detects.
|
||||||
|
|
||||||
|
The reference is a fixed paragraph. It is never modified by the AI, never
|
||||||
|
commented on, and omitted entirely when conditions are not met.
|
||||||
|
|
||||||
|
**Enable:** Set `layer4: true` in `.claude/ai-psychosis.local.md`
|
||||||
|
and restart Claude Code. Layer 4 is opt-in (off by default).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
+------------------------------------------------------------------+
|
||||||
|
| Claude Code Session |
|
||||||
|
| |
|
||||||
|
| +--------------+ +------------------------------------------+ |
|
||||||
|
| | SKILL.md | | Hook Pipeline | |
|
||||||
|
| | (Layer 1) | | | |
|
||||||
|
| | | | SessionStart --> session-start.mjs | |
|
||||||
|
| | Behavioral | | UserPrompt --> prompt-analyzer.mjs | |
|
||||||
|
| | rules that | | PostToolUse --> tool-tracker.mjs | |
|
||||||
|
| | override | | SessionEnd --> session-end.mjs | |
|
||||||
|
| | sycophancy | | | | |
|
||||||
|
| +------+-------+ | +----v------+ | |
|
||||||
|
| | | | lib.mjs | | |
|
||||||
|
| | | | thresholds| | |
|
||||||
|
| Always active | | state mgmt| | |
|
||||||
|
| | | cooldowns | | |
|
||||||
|
| | +----+------+ | |
|
||||||
|
| | | | |
|
||||||
|
| +--------------+-----------+---------------+ |
|
||||||
|
| | |
|
||||||
|
| +--------------v-----------------------+ |
|
||||||
|
| | ${CLAUDE_PLUGIN_DATA}/ | |
|
||||||
|
| | +-- sessions.jsonl | |
|
||||||
|
| | +-- events.jsonl | |
|
||||||
|
| | +-- state/{session_id}.json | |
|
||||||
|
| +--------------------------------------+ |
|
||||||
|
+-------------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
**Layer 1** operates through the Claude Code skill system — instructions
|
||||||
|
loaded into every conversation context.
|
||||||
|
|
||||||
|
**Layer 2** operates through the Claude Code hook system — Node.js scripts
|
||||||
|
that execute on specific lifecycle events and inject `additionalContext`
|
||||||
|
when thresholds are crossed.
|
||||||
|
|
||||||
|
Both layers are independent. Layer 1 works without Layer 2 (instruction-only
|
||||||
|
mode). Layer 2 reinforces Layer 1 with data-driven alerts.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
```
|
||||||
|
/plugin install path:/path/to/ai-psychosis
|
||||||
|
```
|
||||||
|
|
||||||
|
Layer 1 and Layer 2 are active immediately. No configuration needed.
|
||||||
|
|
||||||
|
### Configure layers
|
||||||
|
|
||||||
|
Create `~/.claude/ai-psychosis.local.md` for global config:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
layer2: true
|
||||||
|
layer3: true
|
||||||
|
layer4: false
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
Or override per project at `<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)
|
||||||
256
commands/interaction-report.md
Normal file
256
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
hooks/hooks.json
Normal file
48
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
hooks/scripts/lib.mjs
Normal file
239
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
hooks/scripts/prompt-analyzer.mjs
Normal file
140
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
hooks/scripts/session-end.mjs
Normal file
67
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
hooks/scripts/session-start.mjs
Normal file
69
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
hooks/scripts/tool-tracker.mjs
Normal file
166
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
skills/ai-psychosis/SKILL.md
Normal file
54
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
tests/privacy.test.mjs
Normal file
44
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
tests/prompt-analyzer.test.mjs
Normal file
313
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
tests/session-end.test.mjs
Normal file
66
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
tests/session-start.test.mjs
Normal file
49
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
tests/test-helper.mjs
Normal file
53
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
tests/tool-tracker.test.mjs
Normal file
94
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