feat(hooks): auto-compact prep - context gauge + snapshot stamp (DPLAN-0253). context_gauge (UserPromptSubmit) reads live transcript fill each prompt and nudges /prep at 80 pct of the compact trigger, escalates at 95, once per threshold per session; pre_compact_prep (PreCompact) stamps AUTO-COMPACT SNAPSHOT (fill, dispatch locks, open plans, git, inbox) into the compacting branch local.json; shared context_window resolver (env > branch settings.local.json > 200k). Round 2 root cause: name-scoped events (UserPromptSubmit/PreCompact) invoke the bridge per-handler - hooks.json alone never fires; provider_manifest.json entries added for both + @hooks branch prompt corrected with provider-wire reminder (Patrick-directed). 36 new tests, suite 1190 green, seedgo 100 pct, both re-run by devpulse. Built by @hooks, 2 rounds. Go-live: user syncs ~/.claude/settings.json from manifest + fresh session

This commit is contained in:
AIOSAI
2026-07-20 13:13:43 -07:00
parent d1facd8bdc
commit 33a1510cbb
12 changed files with 1118 additions and 84 deletions
+12
View File
@@ -55,6 +55,12 @@
"matcher": "",
"timeout": 120
},
"context_gauge": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.prompt.context_gauge.handle",
"matcher": "",
"timeout": 30
},
"user_message_relay": {
"enabled": true,
"handler": "aipass.skills.lib.telegram.apps.handlers.user_message_relay.handle",
@@ -158,6 +164,12 @@
"handler": "aipass.hooks.apps.handlers.lifecycle.auto_process.handle",
"matcher": "",
"timeout": 120
},
"pre_compact_prep": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.lifecycle.pre_compact_prep.handle",
"matcher": "",
"timeout": 30
}
},
+4 -1
View File
@@ -14,6 +14,7 @@
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:compass_recall", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:feedback_pulse", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:auto_process", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:context_gauge", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:user_message_relay", "event": "UserPromptSubmit"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreToolUse", "event": "PreToolUse", "matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task"},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PostToolUse", "event": "PostToolUse", "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"},
@@ -23,7 +24,9 @@
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact", "event": "PreCompact", "matcher": "manual", "timeout": 60},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact", "event": "PreCompact", "matcher": "auto", "timeout": 60},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover", "event": "PreCompact", "matcher": "manual", "timeout": 120},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover", "event": "PreCompact", "matcher": "auto", "timeout": 120}
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_rollover", "event": "PreCompact", "matcher": "auto", "timeout": 120},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_prep", "event": "PreCompact", "matcher": "manual", "timeout": 30},
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_prep", "event": "PreCompact", "matcher": "auto", "timeout": 30}
],
"env": {
"AIPASS_HOME": "{{REPO_ROOT}}",
+25
View File
@@ -9,6 +9,31 @@ PyPI version — not the changelog header.
---
## [2026-07-20]
**feat(hooks)** — auto-compact prep: context gauge + mechanical snapshot
(DPLAN-0253, built by @hooks, two rounds):
- `context_gauge` (UserPromptSubmit) — reads live context fill from the session
transcript every prompt (cheap 50KB tail), resolves the branch's compact
window (env > branch `settings.local.json` `autoCompactWindow` > 200k), and
injects a "run /prep NOW" nudge at 80% of the compact trigger, escalating at
95% — once per threshold per session. Memory prep happens before auto-compact
takes the choice away, on every branch including dispatched agents.
- `pre_compact_prep` (PreCompact) — stamps a mechanical AUTO-COMPACT SNAPSHOT
session entry into the compacting branch's `.trinity/local.json`: context
fill %, active dispatch locks, open plans, git state, inbox unread. Templated
from live state, defensive (malformed memory = log + skip, never raises).
- Shared `context_window` module: bounded transcript tail reader + per-branch
window resolver. 36 new tests; suite 1190 green; seedgo 100%.
- Round 2 root-cause fix: handlers wired only in `.aipass/hooks.json` never
fire on name-scoped events — UserPromptSubmit and PreCompact invoke the
bridge per-handler from provider settings. Both handlers now have
`provider_manifest.json` entries; @hooks' branch prompt corrected (it taught
the old one-entry-per-event model) with a "new handler? check the provider
wire" reminder. Go-live needs the user's `~/.claude/settings.json` synced
from the manifest + fresh sessions.
## [2026-07-19]
**feat(ai_mail)** — dispatched agents default to Sonnet 5 (Patrick ruling S326):
@@ -16,7 +16,7 @@ HOOKS -- hook infrastructure owner. Single engine dispatches all hooks across pl
## What I Don't Do
- Touch provider settings directly -- setup.sh/doctor handles platform config installation
- Touch `~/.claude/settings.json` -- personal file, doctor/init syncs it. The manifest (`.claude/provider_manifest.json`) IS mine to maintain
- Manage other branches -- I'm a builder, not an orchestrator
- Own handler business logic -- handlers are self-contained, engine just dispatches
@@ -78,7 +78,7 @@ tests/ # 15 test files, 244 tests
## How It Works
1. Provider settings point ONE bridge entry per event type (e.g., `claude.py UserPromptSubmit`)
1. Provider settings invoke the bridge two ways: `claude.py EventType` (all enabled handlers -- tool events) or `claude.py EventType:handler_name` (one handler per entry -- UserPromptSubmit, PreCompact)
2. Bridge calls `engine.dispatch(event_type, stdin_data, config)`
3. Engine reads `.aipass/hooks.json` (walks up from CWD)
4. Engine runs matching hooks sequentially, logs each to JSONL
@@ -86,6 +86,10 @@ tests/ # 15 test files, 244 tests
6. Exit code 2 without JSON = crash (log error, continue to next hook)
7. All hook stdout concatenated and returned to platform
## New handler? Check the provider wire
hooks.json alone is not live: UserPromptSubmit + PreCompact are invoked per-handler (`claude.py Event:name`) -- handlers on those events ALSO need a command entry in `.claude/provider_manifest.json` (PreCompact: manual + auto pair). Verify with firing evidence in engine.jsonl, not just the suite.
## Integration
- **Depends on:** @prax for logging (system_logger for prax monitor visibility)
@@ -105,4 +109,4 @@ tests/ # 15 test files, 244 tests
- Exit code 2 has dual meaning: intentional block (with JSON) vs crash (without JSON). Engine distinguishes by checking stdout.
- JSONL log lives at `logs/engine.jsonl` -- not in prax. Prax gets a copy via system_logger, but JSONL is the source of truth for hook diagnostics.
- Bridge must be the ONLY entry in provider settings per event type. Multiple entries per event = platform calls them all independently, bypassing engine sequencing.
- Provider settings carry multiple named bridge entries per event for UserPromptSubmit and PreCompact -- deliberate (per-handler output + timeout). New handlers on those events need their own provider entry.
+94 -78
View File
@@ -4,7 +4,7 @@
"created": "2026-05-18",
"updated": "2026-05-28",
"description": "Standards bypass configuration for this branch",
"audit_context": "DPLAN-0191: Full ownership hardening. All handler wiring verified against .aipass/hooks.json + engine.jsonl firing evidence. Dynamic dispatch via engine._run_handler (importlib.import_module + getattr) means handlers are never statically imported \u2014 seedgo's dead_code/unused_function checks are false positives for this architecture."
"audit_context": "DPLAN-0191: Full ownership hardening. All handler wiring verified against .aipass/hooks.json + engine.jsonl firing evidence. Dynamic dispatch via engine._run_handler (importlib.import_module + getattr) means handlers are never statically imported — seedgo's dead_code/unused_function checks are false positives for this architecture."
},
"bypass": [
{
@@ -14,17 +14,17 @@
{
"file": "apps/handlers/bridges/claude.py",
"standard": "dead_code",
"reason": "Bridge called externally by provider settings subprocess \u2014 no internal import. Verified wired in ~/.claude/settings.json hook entries."
"reason": "Bridge called externally by provider settings subprocess — no internal import. Verified wired in ~/.claude/settings.json hook entries."
},
{
"file": "apps/handlers/bridges/claude.py",
"standard": "unused_function",
"reason": "main() called as subprocess entry point from provider settings \u2014 never statically imported."
"reason": "main() called as subprocess entry point from provider settings — never statically imported."
},
{
"file": "apps/handlers/bridges/claude.py",
"standard": "handlers",
"reason": "Bridges import engine module by design \u2014 that is their entire purpose."
"reason": "Bridges import engine module by design — that is their entire purpose."
},
{
"file": "apps/handlers/bridges/claude.py",
@@ -39,27 +39,27 @@
{
"file": "apps/handlers/bridges/claude.py",
"standard": "imports",
"reason": "Bridge imports engine module by design \u2014 sole purpose."
"reason": "Bridge imports engine module by design — sole purpose."
},
{
"file": "apps/handlers/bridges/codex.py",
"standard": "dead_code",
"reason": "Bridge called externally by Codex hook settings subprocess \u2014 no internal import. Wired in .codex/hooks.json."
"reason": "Bridge called externally by Codex hook settings subprocess — no internal import. Wired in .codex/hooks.json."
},
{
"file": "apps/handlers/bridges/codex.py",
"standard": "unused_function",
"reason": "main() called as subprocess entry point from Codex hook settings \u2014 never statically imported."
"reason": "main() called as subprocess entry point from Codex hook settings — never statically imported."
},
{
"file": "apps/handlers/bridges/codex.py",
"standard": "handlers",
"reason": "Bridges import engine module by design \u2014 that is their entire purpose."
"reason": "Bridges import engine module by design — that is their entire purpose."
},
{
"file": "apps/handlers/bridges/codex.py",
"standard": "json_structure",
"reason": "Thin entry point using stdlib json for Codex protocol envelope \u2014 no JSON file ops needing json_handler."
"reason": "Thin entry point using stdlib json for Codex protocol envelope — no JSON file ops needing json_handler."
},
{
"file": "apps/handlers/bridges/codex.py",
@@ -69,12 +69,12 @@
{
"file": "apps/handlers/bridges/codex.py",
"standard": "imports",
"reason": "Bridge imports engine module by design \u2014 sole purpose."
"reason": "Bridge imports engine module by design — sole purpose."
},
{
"file": "apps/handlers/prompt/identity.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.prompt.identity.handle' \u2014 not statically imported by design. Verified wired in UserPromptSubmit.identity_injector + fires in engine.jsonl."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.prompt.identity.handle' — not statically imported by design. Verified wired in UserPromptSubmit.identity_injector + fires in engine.jsonl."
},
{
"file": "apps/handlers/prompt/identity.py",
@@ -84,12 +84,12 @@
{
"file": "apps/handlers/prompt/identity.py",
"standard": "json_structure",
"reason": "Uses stdlib json.loads to read passport.json \u2014 no JSON file ops needing json_handler."
"reason": "Uses stdlib json.loads to read passport.json — no JSON file ops needing json_handler."
},
{
"file": "apps/handlers/prompt/branch_loader.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.prompt.branch_loader.handle' \u2014 not statically imported by design. Verified wired in UserPromptSubmit.branch_prompt + fires in engine.jsonl."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.prompt.branch_loader.handle' — not statically imported by design. Verified wired in UserPromptSubmit.branch_prompt + fires in engine.jsonl."
},
{
"file": "apps/handlers/prompt/branch_loader.py",
@@ -99,7 +99,7 @@
{
"file": "apps/handlers/prompt/branch_loader.py",
"standard": "json_structure",
"reason": "No JSON operations \u2014 reads markdown files and outputs text."
"reason": "No JSON operations — reads markdown files and outputs text."
},
{
"file": "apps/handlers/prompt/tier0_kernel.py",
@@ -134,7 +134,7 @@
{
"file": "apps/handlers/security/edit_gate.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.edit_gate.handle' \u2014 not statically imported by design. Verified wired in PreToolUse.pre_edit_gate + fires in engine.jsonl."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.edit_gate.handle' — not statically imported by design. Verified wired in PreToolUse.pre_edit_gate + fires in engine.jsonl."
},
{
"file": "apps/handlers/security/edit_gate.py",
@@ -144,12 +144,12 @@
{
"file": "apps/handlers/security/edit_gate.py",
"standard": "json_structure",
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses \u2014 no JSON file ops needing json_handler."
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler."
},
{
"file": "apps/handlers/security/git_gate.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.git_gate.handle' \u2014 not statically imported by design. Verified wired in PreToolUse.git_gate + fires in engine.jsonl."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.git_gate.handle' — not statically imported by design. Verified wired in PreToolUse.git_gate + fires in engine.jsonl."
},
{
"file": "apps/handlers/security/git_gate.py",
@@ -159,12 +159,12 @@
{
"file": "apps/handlers/security/git_gate.py",
"standard": "json_structure",
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses \u2014 no JSON file ops needing json_handler."
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler."
},
{
"file": "apps/handlers/security/rm_gate.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.rm_gate.handle' \u2014 not statically imported by design. Wired in PreToolUse.rm_gate."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.rm_gate.handle' — not statically imported by design. Wired in PreToolUse.rm_gate."
},
{
"file": "apps/handlers/security/rm_gate.py",
@@ -174,12 +174,12 @@
{
"file": "apps/handlers/security/rm_gate.py",
"standard": "json_structure",
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses \u2014 no JSON file ops needing json_handler."
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler."
},
{
"file": "apps/handlers/security/subagent_gate.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.subagent_gate.handle' \u2014 not statically imported by design. Verified wired in SubagentStop.subagent_stop_gate + fires in engine.jsonl."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.subagent_gate.handle' — not statically imported by design. Verified wired in SubagentStop.subagent_stop_gate + fires in engine.jsonl."
},
{
"file": "apps/handlers/security/subagent_gate.py",
@@ -189,12 +189,12 @@
{
"file": "apps/handlers/security/subagent_gate.py",
"standard": "json_structure",
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses \u2014 no JSON file ops needing json_handler."
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler."
},
{
"file": "apps/handlers/security/subagent_gate.py",
"standard": "open_encoding",
"reason": "NamedTemporaryFile creates binary wav for Piper TTS \u2014 encoding not applicable to binary audio."
"reason": "NamedTemporaryFile creates binary wav for Piper TTS — encoding not applicable to binary audio."
},
{
"file": "apps/handlers/security/registry_gate.py",
@@ -244,7 +244,7 @@
{
"file": "apps/handlers/lifecycle/auto_fix.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_fix.handle' \u2014 not statically imported by design. Verified wired in PostToolUse.auto_fix_diagnostics + fires in engine.jsonl."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_fix.handle' — not statically imported by design. Verified wired in PostToolUse.auto_fix_diagnostics + fires in engine.jsonl."
},
{
"file": "apps/handlers/lifecycle/auto_fix.py",
@@ -254,12 +254,12 @@
{
"file": "apps/handlers/lifecycle/auto_fix.py",
"standard": "json_structure",
"reason": "Diagnostics handler uses stdlib json for hook protocol responses and state file \u2014 no JSON file ops needing json_handler."
"reason": "Diagnostics handler uses stdlib json for hook protocol responses and state file — no JSON file ops needing json_handler."
},
{
"file": "apps/handlers/lifecycle/auto_watchdog.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_watchdog.handle' \u2014 not statically imported by design. Verified wired in PostToolUse.auto_watchdog + fires in engine.jsonl."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_watchdog.handle' — not statically imported by design. Verified wired in PostToolUse.auto_watchdog + fires in engine.jsonl."
},
{
"file": "apps/handlers/lifecycle/auto_watchdog.py",
@@ -269,42 +269,42 @@
{
"file": "apps/handlers/lifecycle/auto_watchdog.py",
"standard": "json_structure",
"reason": "Uses stdlib json.dumps to produce additionalContext output \u2014 no JSON file ops needing json_handler."
"reason": "Uses stdlib json.dumps to produce additionalContext output — no JSON file ops needing json_handler."
},
{
"file": "apps/handlers/lifecycle/compact.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.compact.handle' \u2014 not statically imported by design. Verified wired in PreCompact.pre_compact (PreCompact events are rare \u2014 fires only during context compaction)."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.compact.handle' — not statically imported by design. Verified wired in PreCompact.pre_compact (PreCompact events are rare — fires only during context compaction)."
},
{
"file": "apps/handlers/lifecycle/compact.py",
"standard": "unused_function",
"reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in PreCompact.pre_compact \u2014 fires during compaction events."
"reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in PreCompact.pre_compact — fires during compaction events."
},
{
"file": "apps/handlers/lifecycle/compact.py",
"standard": "json_structure",
"reason": "Uses stdlib json.loads for local.json reading \u2014 no JSON file ops needing json_handler."
"reason": "Uses stdlib json.loads for local.json reading — no JSON file ops needing json_handler."
},
{
"file": "apps/handlers/lifecycle/rollover.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.rollover.handle' \u2014 not statically imported by design. Verified wired in PreCompact.pre_compact_rollover (PreCompact events are rare \u2014 fires only during context compaction)."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.rollover.handle' — not statically imported by design. Verified wired in PreCompact.pre_compact_rollover (PreCompact events are rare — fires only during context compaction)."
},
{
"file": "apps/handlers/lifecycle/rollover.py",
"standard": "unused_function",
"reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in PreCompact.pre_compact_rollover \u2014 fires during compaction events."
"reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in PreCompact.pre_compact_rollover — fires during compaction events."
},
{
"file": "apps/handlers/lifecycle/rollover.py",
"standard": "json_structure",
"reason": "Uses stdlib json.loads for registry and memory file checks \u2014 no JSON file ops needing json_handler."
"reason": "Uses stdlib json.loads for registry and memory file checks — no JSON file ops needing json_handler."
},
{
"file": "apps/handlers/lifecycle/auto_process.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_process.handle' \u2014 not statically imported by design. Wired in UserPromptSubmit.auto_process + PreCompact.auto_process."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_process.handle' — not statically imported by design. Wired in UserPromptSubmit.auto_process + PreCompact.auto_process."
},
{
"file": "apps/handlers/lifecycle/auto_process.py",
@@ -314,12 +314,12 @@
{
"file": "apps/handlers/lifecycle/auto_process.py",
"standard": "json_structure",
"reason": "Delegates to @memory's auto_process() via importlib \u2014 no direct JSON file ops needing json_handler."
"reason": "Delegates to @memory's auto_process() via importlib — no direct JSON file ops needing json_handler."
},
{
"file": "apps/handlers/lifecycle/session_start.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.session_start.handle' \u2014 not statically imported by design. Wired in SessionStart.cadence_reset."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.session_start.handle' — not statically imported by design. Wired in SessionStart.cadence_reset."
},
{
"file": "apps/handlers/lifecycle/session_start.py",
@@ -329,12 +329,12 @@
{
"file": "apps/handlers/lifecycle/session_start.py",
"standard": "json_structure",
"reason": "Delegates to cadence.reset_counter() via importlib \u2014 no direct JSON file ops needing json_handler."
"reason": "Delegates to cadence.reset_counter() via importlib — no direct JSON file ops needing json_handler."
},
{
"file": "apps/modules/cadence.py",
"standard": "dead_code",
"reason": "Cadence module \u2014 should_fire() called from tier0_kernel.py, navmap.py, and branch_loader.py guard lines; reset_counter() called from compact.py PreCompact handler. Not statically discoverable because callers are themselves dynamically dispatched."
"reason": "Cadence module — should_fire() called from tier0_kernel.py, navmap.py, and branch_loader.py guard lines; reset_counter() called from compact.py PreCompact handler. Not statically discoverable because callers are themselves dynamically dispatched."
},
{
"file": "apps/modules/cadence.py",
@@ -344,12 +344,12 @@
{
"file": "apps/modules/cadence.py",
"standard": "json_structure",
"reason": "Uses stdlib json for /tmp state file (turn counter) and hooks_json/custom_config/cadence_config.json config loading \u2014 lightweight ephemeral state, not branch data storage."
"reason": "Uses stdlib json for /tmp state file (turn counter) and hooks_json/custom_config/cadence_config.json config loading — lightweight ephemeral state, not branch data storage."
},
{
"file": "apps/handlers/notification/announce.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.announce.handle' \u2014 not statically imported by design. Verified wired in Notification.notification_sound + fires in engine.jsonl."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.announce.handle' — not statically imported by design. Verified wired in Notification.notification_sound + fires in engine.jsonl."
},
{
"file": "apps/handlers/notification/announce.py",
@@ -359,12 +359,12 @@
{
"file": "apps/handlers/notification/announce.py",
"standard": "json_structure",
"reason": "Sound handler \u2014 no JSON operations, plays WAV files."
"reason": "Sound handler — no JSON operations, plays WAV files."
},
{
"file": "apps/handlers/notification/email.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.email.handle' \u2014 not statically imported by design. Verified wired in UserPromptSubmit.email_notification + fires in engine.jsonl."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.email.handle' — not statically imported by design. Verified wired in UserPromptSubmit.email_notification + fires in engine.jsonl."
},
{
"file": "apps/handlers/notification/email.py",
@@ -374,12 +374,12 @@
{
"file": "apps/handlers/notification/email.py",
"standard": "json_structure",
"reason": "Uses stdlib json.loads for inbox parsing \u2014 no JSON file ops needing json_handler."
"reason": "Uses stdlib json.loads for inbox parsing — no JSON file ops needing json_handler."
},
{
"file": "apps/handlers/notification/stop_sound.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.stop_sound.handle' \u2014 not statically imported by design. Verified wired in Stop.stop_sound + fires in engine.jsonl."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.stop_sound.handle' — not statically imported by design. Verified wired in Stop.stop_sound + fires in engine.jsonl."
},
{
"file": "apps/handlers/notification/stop_sound.py",
@@ -389,12 +389,12 @@
{
"file": "apps/handlers/notification/stop_sound.py",
"standard": "json_structure",
"reason": "Sound handler \u2014 no JSON operations, plays WAV files."
"reason": "Sound handler — no JSON operations, plays WAV files."
},
{
"file": "apps/handlers/notification/tool_sound.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.tool_sound.handle' \u2014 not statically imported by design. Verified wired in PreToolUse.tool_use_sound + fires in engine.jsonl."
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.tool_sound.handle' — not statically imported by design. Verified wired in PreToolUse.tool_use_sound + fires in engine.jsonl."
},
{
"file": "apps/handlers/notification/tool_sound.py",
@@ -404,7 +404,7 @@
{
"file": "apps/handlers/notification/tool_sound.py",
"standard": "json_structure",
"reason": "Sound handler \u2014 no JSON operations, plays WAV files."
"reason": "Sound handler — no JSON operations, plays WAV files."
},
{
"file": "apps/handlers/config/trust_registry.py",
@@ -414,22 +414,22 @@
{
"file": "apps/handlers/config/loader.py",
"standard": "json_structure",
"reason": "Config loader does $AIPASS_HOME variable expansion before JSON parse \u2014 json_handler does not support this."
"reason": "Config loader does $AIPASS_HOME variable expansion before JSON parse — json_handler does not support this."
},
{
"file": "apps/handlers/config/diagnostics.py",
"standard": "json_structure",
"reason": "JSONL append-only diagnostic log \u2014 different pattern from branch json_handler storage."
"reason": "JSONL append-only diagnostic log — different pattern from branch json_handler storage."
},
{
"file": "apps/modules/engine.py",
"standard": "json_structure",
"reason": "Engine uses JSONL diagnostic logging, not branch json_handler \u2014 different purpose."
"reason": "Engine uses JSONL diagnostic logging, not branch json_handler — different purpose."
},
{
"file": "apps/modules/engine.py",
"standard": "modules",
"reason": "dispatch() is the engine's core purpose \u2014 it IS the module's primary function, not a handler that belongs elsewhere. The engine exists to dispatch; moving dispatch to handlers/ would leave an empty module."
"reason": "dispatch() is the engine's core purpose — it IS the module's primary function, not a handler that belongs elsewhere. The engine exists to dispatch; moving dispatch to handlers/ would leave an empty module."
},
{
"file": "apps/modules/hook_test.py",
@@ -439,32 +439,32 @@
{
"file": "apps/modules/hooksound.py",
"standard": "json_structure",
"reason": "Sound mute toggle \u2014 touches /tmp/aipass-hooks-muted flag file only, no JSON operations or json_handler storage."
"reason": "Sound mute toggle — touches /tmp/aipass-hooks-muted flag file only, no JSON operations or json_handler storage."
},
{
"file": "apps/modules/hooksound.py",
"standard": "trigger",
"reason": "MUTE_FLAG.unlink() removes a /tmp mute flag file for sound toggle \u2014 not a tracked resource or production data deletion. Deliberate user action via 'drone @hooks hooksound on'."
"reason": "MUTE_FLAG.unlink() removes a /tmp mute flag file for sound toggle — not a tracked resource or production data deletion. Deliberate user action via 'drone @hooks hooksound on'."
},
{
"file": "apps/modules/feedback.py",
"standard": "trigger",
"reason": "sentinel.unlink() removes a .aipass/feedback_off toggle file \u2014 not a tracked resource. Deliberate user action via 'drone @hooks feedback on'."
"reason": "sentinel.unlink() removes a .aipass/feedback_off toggle file — not a tracked resource. Deliberate user action via 'drone @hooks feedback on'."
},
{
"file": "apps/modules/hookstatus.py",
"standard": "json_structure",
"reason": "Read-only config viewer \u2014 delegates JSON loading to config/loader.py, no direct JSON file ops."
"reason": "Read-only config viewer — delegates JSON loading to config/loader.py, no direct JSON file ops."
},
{
"file": "apps/modules/wire_verify.py",
"standard": "json_structure",
"reason": "Reads ~/.claude/settings.json (external provider settings) with stdlib json \u2014 not branch data storage needing json_handler."
"reason": "Reads ~/.claude/settings.json (external provider settings) with stdlib json — not branch data storage needing json_handler."
},
{
"file": "apps/modules/cadence.py",
"standard": "modules",
"reason": "Cadence module reads /tmp state file and hooks_json/custom_config/cadence_config.json with stdlib json \u2014 lightweight ephemeral state (turn counter) + tunable config. json_handler is for persistent branch data, not /tmp session state or config knobs."
"reason": "Cadence module reads /tmp state file and hooks_json/custom_config/cadence_config.json with stdlib json — lightweight ephemeral state (turn counter) + tunable config. json_handler is for persistent branch data, not /tmp session state or config knobs."
},
{
"file": "apps/hooks.py",
@@ -474,17 +474,17 @@
{
"file": "apps/sound.py",
"standard": "unused_function",
"reason": "play() called by handler files (stop_sound.py, announce.py) that are dynamically dispatched via importlib \u2014 static analysis cannot trace the call chain from hooks.json \u2192 engine \u2192 handler \u2192 sound.play()."
"reason": "play() called by handler files (stop_sound.py, announce.py) that are dynamically dispatched via importlib — static analysis cannot trace the call chain from hooks.json → engine → handler → sound.play()."
},
{
"file": "apps/sound.py",
"standard": "open_encoding",
"reason": "NamedTemporaryFile creates binary WAV for Piper TTS \u2014 encoding not applicable to binary audio."
"reason": "NamedTemporaryFile creates binary WAV for Piper TTS — encoding not applicable to binary audio."
},
{
"file": "apps/sound.py",
"standard": "cli_flags",
"reason": "Shared utility module imported by handlers \u2014 not a CLI entry point. Has print_introspection() for drone discovery but no handle_command() or user-facing CLI."
"reason": "Shared utility module imported by handlers — not a CLI entry point. Has print_introspection() for drone discovery but no handle_command() or user-facing CLI."
},
{
"standard": "json_handler",
@@ -492,7 +492,7 @@
},
{
"standard": "test_quality",
"reason": "Hooks branch does not use json_handler \u2014 has its own JSONL diagnostic logging (diagnostics.py) and stdlib json for hook protocol I/O. json_handler coverage, mock_json_handler fixture, and exception_contracts (create_default_raises, save_invalid_raises, invalid_mode_raises) are all N/A for a hook dispatch engine architecture."
"reason": "Hooks branch does not use json_handler — has its own JSONL diagnostic logging (diagnostics.py) and stdlib json for hook protocol I/O. json_handler coverage, mock_json_handler fixture, and exception_contracts (create_default_raises, save_invalid_raises, invalid_mode_raises) are all N/A for a hook dispatch engine architecture."
},
{
"file": "tests/conftest.py",
@@ -502,12 +502,12 @@
{
"file": "tests/conftest.py",
"standard": "json_handler",
"reason": "Hooks branch does not use json_handler \u2014 has its own JSONL logging and stdlib json for hook protocol. mock_json_handler fixture is N/A."
"reason": "Hooks branch does not use json_handler — has its own JSONL logging and stdlib json for hook protocol. mock_json_handler fixture is N/A."
},
{
"file": "tests/conftest.py",
"standard": "exception_contracts",
"reason": "Hooks has no json_handler create_default/save_invalid/invalid_mode patterns \u2014 those contracts are N/A for a hook dispatch engine."
"reason": "Hooks has no json_handler create_default/save_invalid/invalid_mode patterns — those contracts are N/A for a hook dispatch engine."
},
{
"file": "tests/test_engine.py",
@@ -532,7 +532,7 @@
{
"file": "tests/test_engine.py",
"standard": "json_handler",
"reason": "Hooks branch does not use json_handler \u2014 has its own JSONL logging."
"reason": "Hooks branch does not use json_handler — has its own JSONL logging."
},
{
"file": "tests/test_engine.py",
@@ -662,12 +662,12 @@
{
"file": "tests/test_auto_fix.py",
"standard": "commented_logger",
"reason": "Test data contains '# logger.debug(msg)' as input to pattern checker under test \u2014 not a commented-out call."
"reason": "Test data contains '# logger.debug(msg)' as input to pattern checker under test — not a commented-out call."
},
{
"file": "tests/test_auto_fix.py",
"standard": "trigger",
"reason": "Test cleanup .unlink() removes temporary state files \u2014 not a production file deletion."
"reason": "Test cleanup .unlink() removes temporary state files — not a production file deletion."
},
{
"file": "tests/test_identity.py",
@@ -957,47 +957,47 @@
{
"file": "apps/modules/sandbox.py",
"standard": "json_structure",
"reason": "Uses stdlib json.dump to write ephemeral srt config to a NamedTemporaryFile \u2014 not a tracked JSON resource needing json_handler."
"reason": "Uses stdlib json.dump to write ephemeral srt config to a NamedTemporaryFile — not a tracked JSON resource needing json_handler."
},
{
"file": "apps/modules/sandbox.py",
"standard": "trigger",
"reason": "Path.unlink() removes an ephemeral NamedTemporaryFile (srt config) created seconds earlier in the same function \u2014 not a tracked resource or production data deletion."
"reason": "Path.unlink() removes an ephemeral NamedTemporaryFile (srt config) created seconds earlier in the same function — not a tracked resource or production data deletion."
},
{
"file": "artifacts/sandbox_phase1_demo.py",
"standard": "architecture",
"reason": "Live demo artifact for FPLAN-0250 Phase 1 acceptance \u2014 not production code, lives in artifacts/ per the phase brief."
"reason": "Live demo artifact for FPLAN-0250 Phase 1 acceptance — not production code, lives in artifacts/ per the phase brief."
},
{
"file": "artifacts/sandbox_phase1_demo.py",
"standard": "debug_print",
"reason": "Demo script uses print() for human-readable acceptance test output \u2014 not a module with CLI service."
"reason": "Demo script uses print() for human-readable acceptance test output — not a module with CLI service."
},
{
"file": "artifacts/sandbox_phase1_demo.py",
"standard": "imports",
"reason": "sys.path insert needed to run standalone demo from artifacts/ \u2014 not a pip-installed module entry point."
"reason": "sys.path insert needed to run standalone demo from artifacts/ — not a pip-installed module entry point."
},
{
"file": "artifacts/sandbox_phase1_demo.py",
"standard": "documentation",
"reason": "Demo helper function \u2014 docstrings omitted for brevity in a non-production artifact."
"reason": "Demo helper function — docstrings omitted for brevity in a non-production artifact."
},
{
"file": "artifacts/sandbox_phase1_demo.py",
"standard": "help_text",
"reason": "Demo run instructions reference python3 as the invocation command \u2014 this is a standalone script, not a drone-routed module."
"reason": "Demo run instructions reference python3 as the invocation command — this is a standalone script, not a drone-routed module."
},
{
"file": "tests/test_sandbox.py",
"standard": "architecture",
"reason": "Test file lives in tests/ per hooks convention \u2014 not a module or handler."
"reason": "Test file lives in tests/ per hooks convention — not a module or handler."
},
{
"file": "tests/test_sandbox.py",
"standard": "documentation",
"reason": "Test methods use descriptive names \u2014 docstrings redundant per hooks test convention."
"reason": "Test methods use descriptive names — docstrings redundant per hooks test convention."
},
{
"file": "tests/test_sandbox.py",
@@ -1007,7 +1007,7 @@
{
"file": "apps/modules/sandbox.py",
"standard": "modules",
"reason": "Read-only passport.json check in _is_devpulse() to detect branch role for policy generation \u2014 not a file-write operation, not a handler-level concern. Module reads sibling passports to determine writable/RO map."
"reason": "Read-only passport.json check in _is_devpulse() to detect branch role for policy generation — not a file-write operation, not a handler-level concern. Module reads sibling passports to determine writable/RO map."
},
{
"file": "apps/modules/sandbox.py",
@@ -1248,10 +1248,26 @@
"file": "tests/test_wire_verify.py",
"standard": "help_text",
"reason": "Test fixture _BRIDGE_CMD contains 'python3' as part of a mock provider command string — not user-facing help text."
},
{
"file": "apps/modules/context_window.py",
"standard": "json_structure",
"reason": "Reads external state (session transcript JSONL via tail, branch .claude/settings.local.json) with stdlib json — not branch data storage needing json_handler. The module IS the transcript/window reader service (DPLAN-0253), same pattern as cc_sessions.py."
},
{
"file": "apps/handlers/lifecycle/pre_compact_prep.py",
"standard": "json_structure",
"reason": "Uses stdlib json for .trinity/local.json read+stamp (own branch's memory file, DPLAN-0253) plus AIPASS_REGISTRY.json/inbox.json/memory.config.json reads (external system + other branches' state) — not this branch's own hooks_json/ triplet storage. Matches compact.py precedent (same local.json access)."
},
{
"file": "apps/handlers/prompt/context_gauge.py",
"standard": "json_structure",
"reason": "Delegates to context_window module for transcript/settings reads — no direct JSON file ops needing json_handler. Same pattern as session_start.py delegating to cadence."
}
],
"notes": {
"removed_2026-05-19": "Stripped 4 illegitimate bypasses \u2014 hooks.py/cli, hooks.py/cli_flags, engine.py/modules, engine.py/introspection. Code fixed to meet standards instead.",
"dplan_0191_2026-05-28": "Added dead_code + unused_function bypasses for all 15 dynamically-dispatched handlers after verifying each is wired in .aipass/hooks.json AND fires in engine.jsonl. Root cause: engine._run_handler (engine.py:60-66) uses importlib.import_module + getattr on hooks.json handler strings \u2014 handlers are never statically imported. Follow-up for @seedgo: teach dead_code/unused_function about dynamic importlib dispatch patterns."
"removed_2026-05-19": "Stripped 4 illegitimate bypasses — hooks.py/cli, hooks.py/cli_flags, engine.py/modules, engine.py/introspection. Code fixed to meet standards instead.",
"dplan_0191_2026-05-28": "Added dead_code + unused_function bypasses for all 15 dynamically-dispatched handlers after verifying each is wired in .aipass/hooks.json AND fires in engine.jsonl. Root cause: engine._run_handler (engine.py:60-66) uses importlib.import_module + getattr on hooks.json handler strings — handlers are never statically imported. Follow-up for @seedgo: teach dead_code/unused_function about dynamic importlib dispatch patterns.",
"dplan_0253_2026-07-20": "Added json_structure bypasses for context_window.py, pre_compact_prep.py, context_gauge.py — same external-state-read profile as existing compact.py/cc_sessions.py/session_start.py bypasses."
}
}
+5 -2
View File
@@ -66,6 +66,7 @@ src/aipass/hooks/
│ ├── sound.py # Shared sound utilities (speak, play, mute)
│ ├── modules/
│ │ ├── cadence.py # Prompt injection cadence (every-Nth-turn gating)
│ │ ├── context_window.py # Transcript usage reader + per-branch compact-window resolver
│ │ ├── hook_test.py # Portable test runner (drone @hooks test)
│ │ ├── cc_sessions.py # CC-native session file reader (~/.claude/sessions/<pid>.json)
│ │ ├── engine.py # Core dispatch — routes events to handlers
@@ -86,6 +87,7 @@ src/aipass/hooks/
│ │ │ ├── navmap.py # Injects tier1 navmap prompt (periodic)
│ │ │ ├── identity.py # Injects passport identity block
│ │ │ ├── feedback_pulse.py # Periodic feedback ask (~10 turns, toggleable)
│ │ │ ├── context_gauge.py # Nudges /prep before auto-compact fires (80%/95% of window)
│ │ │ └── persistent_alert.py # Injects advisory banners from .aipass/alerts.json
│ │ ├── security/ # Enforcement hooks
│ │ │ ├── edit_gate.py # Blocks unsafe edits (cross-branch, inbox, diagnostics)
@@ -98,6 +100,7 @@ src/aipass/hooks/
│ │ │ ├── auto_fix.py # Post-edit diagnostics (ruff, pyright, py_compile)
│ │ │ ├── auto_watchdog.py # Watchdog arming after dispatch
│ │ │ ├── compact.py # Pre-compact memory archival
│ │ │ ├── pre_compact_prep.py # Mechanical AUTO-COMPACT SNAPSHOT stamp (fill %, git, locks, plans)
│ │ │ ├── rollover.py # Pre-compact memory rollover
│ │ │ └── session_start.py # Cadence reset on new chat / clear (SessionStart)
│ │ └── notification/ # Sound/alert hooks
@@ -132,13 +135,13 @@ Handlers are called **dynamically at runtime** — the engine uses `importlib.im
| Event | Hooks | Description |
|---|---|---|
| UserPromptSubmit | presence_gate, persistent_alert, identity, email, branch_loader, tier0_kernel, navmap, feedback_pulse, auto_process, user_message_relay | Presence gate + alerts + prompt injection + inbox + feedback + auto-process + TG mirror |
| UserPromptSubmit | presence_gate, persistent_alert, identity, email, branch_loader, tier0_kernel, navmap, feedback_pulse, context_gauge, auto_process, user_message_relay | Presence gate + alerts + prompt injection + inbox + feedback + context gauge + auto-process + TG mirror |
| PreToolUse | tool_sound, edit_gate, git_gate, rm_gate, registry_gate | Security gates + guardrails + sound |
| PostToolUse | auto_fix, auto_watchdog | Diagnostics + watchdog |
| SubagentStop | subagent_gate | Seedgo validation |
| Stop | stop_sound, telegram_response, presence_release | Bell + Telegram delivery + presence release |
| Notification | announce | Announcement tone |
| PreCompact | compact, rollover | Memory archival + rollover |
| PreCompact | compact, rollover, pre_compact_prep | Memory archival + rollover + mechanical snapshot stamp |
## Git Gate
@@ -0,0 +1,246 @@
# =================== AIPass ====================
# Name: pre_compact_prep.py
# Version: 1.0.0
# Description: Stamps a mechanical AUTO-COMPACT SNAPSHOT into the compacting branch's memory (PreCompact)
# Branch: hooks
# Layer: apps/handlers/lifecycle
# Created: 2026-07-20
# Modified: 2026-07-20
# =============================================
"""Mechanical /prep AT compact time (DPLAN-0253).
Resolves the compacting branch from cwd, then prepends a session entry to its
.trinity/local.json — context fill, in-flight dispatch locks across the
system, this branch's open plan count, git state, and inbox unread count.
Templated from live state, no model turn needed. The judgment layer (todo
reconcile, thoughtful summary) is handled separately by the context gauge
nudge, which fires early enough for a live model turn to run /prep.
Defensive: a missing or malformed .trinity/local.json is logged and skipped —
this handler must never corrupt memory or raise out of PreCompact."""
import importlib
import json
import subprocess
from datetime import date
from pathlib import Path
from aipass.prax.apps.modules.logger import system_logger as logger
_DEFAULT_SUMMARY_CAP = 300
def _find_repo_root(start: Path) -> Path | None:
for parent in [start, *start.parents]:
if (parent / "AIPASS_REGISTRY.json").exists():
return parent
return None
def _context_fill_pct(hook_data: dict, cwd: str) -> str | None:
context_window = importlib.import_module("aipass.hooks.apps.modules.context_window")
usage = context_window.read_latest_usage(hook_data.get("transcript_path", ""))
if usage is None:
return None
fill = context_window.context_fill_tokens(usage)
window = context_window.resolve_compact_window(cwd)
if window <= 0:
return None
pct = round(fill / window * 100)
return f"~{fill // 1000}k/{window // 1000}k ({pct}%)"
def _count_active_dispatch_locks(repo_root: Path | None) -> int | None:
if repo_root is None:
return None
registry_path = repo_root / "AIPASS_REGISTRY.json"
try:
data = json.loads(registry_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
logger.info("[HOOKS] pre_compact_prep: registry read failed: %s", exc)
return None
count = 0
for branch in data.get("branches", []):
branch_path = repo_root / branch.get("path", "")
lock_path = branch_path / ".ai_mail.local" / ".dispatch.lock"
if lock_path.is_file():
count += 1
return count
def _count_open_plans(branch_dir: Path) -> int | None:
try:
from aipass.flow.apps.handlers.plan.get_open_plans import get_open_plans
target = str(branch_dir.resolve())
return sum(1 for _num, info in get_open_plans() if info.get("location") == target)
except Exception as exc:
logger.info("[HOOKS] pre_compact_prep: open plans read failed: %s", exc)
return None
def _git_snapshot(branch_dir: Path) -> str | None:
try:
branch = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
text=True,
timeout=5,
cwd=str(branch_dir),
)
last_commit = subprocess.run(
["git", "log", "-1", "--format=%h %s"],
capture_output=True,
text=True,
timeout=5,
cwd=str(branch_dir),
)
dirty = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True,
text=True,
timeout=5,
cwd=str(branch_dir),
)
except Exception as exc:
logger.info("[HOOKS] pre_compact_prep: git snapshot failed: %s", exc)
return None
bits: list[str] = []
if branch.returncode == 0 and branch.stdout.strip():
bits.append(branch.stdout.strip())
if last_commit.returncode == 0 and last_commit.stdout.strip():
bits.append(last_commit.stdout.strip())
if dirty.returncode == 0:
dirty_lines = [ln for ln in dirty.stdout.strip().split("\n") if ln]
bits.append(f"{len(dirty_lines)} dirty")
return " / ".join(bits) if bits else None
def _inbox_unread(branch_dir: Path) -> int | None:
inbox_path = branch_dir / ".ai_mail.local" / "inbox.json"
if not inbox_path.is_file():
return None
try:
data = json.loads(inbox_path.read_text(encoding="utf-8"))
unread = data.get("unread_count")
return unread if isinstance(unread, int) else None
except (OSError, json.JSONDecodeError) as exc:
logger.info("[HOOKS] pre_compact_prep: inbox read failed: %s", exc)
return None
def _summary_cap(repo_root: Path | None) -> int:
if repo_root is None:
return _DEFAULT_SUMMARY_CAP
config_path = repo_root / "src" / "aipass" / "memory" / "memory_json" / "custom_config" / "memory.config.json"
try:
data = json.loads(config_path.read_text(encoding="utf-8"))
cap = data["entry_limits"]["entry_types"]["sessions"]["max_chars"]
if isinstance(cap, int) and cap > 0:
return cap
except (OSError, json.JSONDecodeError, KeyError, TypeError) as exc:
logger.info("[HOOKS] pre_compact_prep: entry_limits read failed, using default: %s", exc)
return _DEFAULT_SUMMARY_CAP
def _build_snapshot(hook_data: dict, branch_dir: Path, repo_root: Path | None) -> str:
cwd = hook_data.get("cwd", "") or str(branch_dir)
parts: list[str] = []
fill = _context_fill_pct(hook_data, cwd)
if fill:
parts.append(f"context {fill}")
locks = _count_active_dispatch_locks(repo_root)
if locks is not None:
parts.append(f"{locks} active dispatch(es)")
open_plans = _count_open_plans(branch_dir)
if open_plans is not None:
parts.append(f"{open_plans} open plan(s)")
git_info = _git_snapshot(branch_dir)
if git_info:
parts.append(f"git: {git_info}")
unread = _inbox_unread(branch_dir)
if unread is not None:
parts.append(f"{unread} unread")
body = ", ".join(parts) if parts else "no live state available"
return f"AUTO-COMPACT SNAPSHOT: {body}"
def _stamp_session_entry(branch_dir: Path, summary: str, cap: int) -> bool:
local_path = branch_dir / ".trinity" / "local.json"
if not local_path.is_file():
logger.info("[HOOKS] pre_compact_prep: no local.json at %s — skipping stamp", local_path)
return False
try:
data = json.loads(local_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
logger.warning("[HOOKS] pre_compact_prep: local.json unreadable, skipping stamp: %s", exc)
return False
if not isinstance(data, dict):
logger.warning("[HOOKS] pre_compact_prep: local.json malformed (not a dict), skipping stamp")
return False
sessions = data.get("sessions")
if not isinstance(sessions, list):
logger.warning("[HOOKS] pre_compact_prep: sessions container malformed, skipping stamp")
return False
truncated = summary if len(summary) <= cap else summary[: cap - 1].rstrip() + "…"
existing_numbers = [
entry.get("number", 0) for entry in sessions if isinstance(entry, dict) and isinstance(entry.get("number"), int)
]
next_number = max(existing_numbers, default=0) + 1
sessions.insert(
0,
{
"date": date.today().isoformat(),
"summary": truncated,
"status": "auto-compact",
"number": next_number,
},
)
data["sessions"] = sessions
try:
local_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
except OSError as exc:
logger.warning("[HOOKS] pre_compact_prep: local.json write failed: %s", exc)
return False
return True
def handle(hook_data: dict) -> dict:
"""Stamp a mechanical AUTO-COMPACT SNAPSHOT into the compacting branch's memory."""
try:
cwd = hook_data.get("cwd", "") or str(Path.cwd())
context_window = importlib.import_module("aipass.hooks.apps.modules.context_window")
branch_dir = context_window.find_branch_dir(cwd)
if branch_dir is None:
logger.info("[HOOKS] pre_compact_prep: no branch dir resolved from cwd=%s", cwd)
return {"stdout": "", "exit_code": 0}
repo_root = _find_repo_root(branch_dir)
cap = _summary_cap(repo_root)
snapshot = _build_snapshot(hook_data, branch_dir, repo_root)
stamped = _stamp_session_entry(branch_dir, snapshot, cap)
logger.info("[HOOKS] pre_compact_prep: snapshot stamped=%s branch=%s", stamped, branch_dir.name)
return {"stdout": snapshot, "exit_code": 0}
except Exception as exc:
logger.info("[HOOKS] pre_compact_prep: unexpected error: %s", exc)
return {"stdout": "", "exit_code": 0}
@@ -0,0 +1,104 @@
# =================== AIPass ====================
# Name: context_gauge.py
# Version: 1.0.0
# Description: Nudges the model to run /prep before auto-compact fires (UserPromptSubmit, DPLAN-0253)
# Branch: hooks
# Layer: apps/handlers/prompt
# Created: 2026-07-20
# Modified: 2026-07-20
# =============================================
"""Early-warning nudge so the model runs /prep before the compact ceiling —
memory should never be at the mercy of an auto-compact firing mid-work.
Reads live transcript usage every prompt (cheap tail read), resolves the
branch's compact trigger (window * 0.9), and injects a hard line once fill
crosses 80%/95% of that trigger. Independent of the cadence system — its own
per-session, per-threshold guard file in tempdir, same idiom as
feedback_pulse.py / auto_process.py, so it isn't gated by turn count."""
import importlib
import os
import tempfile
from pathlib import Path
from aipass.prax.apps.modules.logger import system_logger as logger
_GUARD_DIR = Path(tempfile.gettempdir())
_TRIGGER_RATIO = 0.9
_NUDGE_THRESHOLD_PCT = 80
_ESCALATE_THRESHOLD_PCT = 95
def _guard_path(session_id: str, threshold: str) -> Path | None:
if not session_id:
return None
return _GUARD_DIR / f"aipass-context-gauge-{session_id}-{threshold}"
def _already_fired(session_id: str, threshold: str) -> bool:
path = _guard_path(session_id, threshold)
return path is not None and path.exists()
def _mark_fired(session_id: str, threshold: str) -> None:
path = _guard_path(session_id, threshold)
if path is not None:
try:
path.touch()
except OSError as exc:
logger.info("[HOOKS] context_gauge: guard write failed: %s", exc)
def handle(hook_data: dict) -> dict:
"""Inject a context-fill nudge once per threshold per session."""
try:
session_id = hook_data.get("session_id", "") or os.environ.get("CLAUDE_CODE_SESSION_ID", "")
transcript_path = hook_data.get("transcript_path", "")
if not transcript_path:
return {"stdout": "", "exit_code": 0}
context_window = importlib.import_module("aipass.hooks.apps.modules.context_window")
usage = context_window.read_latest_usage(transcript_path)
if usage is None:
return {"stdout": "", "exit_code": 0}
fill = context_window.context_fill_tokens(usage)
cwd = hook_data.get("cwd", "") or str(Path.cwd())
window = context_window.resolve_compact_window(cwd)
trigger = window * _TRIGGER_RATIO
if trigger <= 0:
return {"stdout": "", "exit_code": 0}
pct = fill / trigger * 100
fill_k = fill // 1000
trigger_k = int(trigger) // 1000
if pct >= _ESCALATE_THRESHOLD_PCT and not _already_fired(session_id, "95"):
_mark_fired(session_id, "95")
_mark_fired(session_id, "80")
logger.info("[HOOKS] context_gauge: escalate fired at %.0f%% session=%s", pct, session_id[:8])
return {
"stdout": (
f"CONTEXT GAUGE: ~{fill_k}k/{trigger_k}k ({pct:.0f}%) — run /prep NOW "
"AND wrap up the current work item. Auto-compact is imminent."
),
"exit_code": 0,
}
if pct >= _NUDGE_THRESHOLD_PCT and not _already_fired(session_id, "80"):
_mark_fired(session_id, "80")
logger.info("[HOOKS] context_gauge: nudge fired at %.0f%% session=%s", pct, session_id[:8])
return {
"stdout": (
f"CONTEXT GAUGE: ~{fill_k}k/{trigger_k}k ({pct:.0f}%) — run /prep NOW, "
"before auto-compact takes the choice away."
),
"exit_code": 0,
}
return {"stdout": "", "exit_code": 0}
except Exception as exc:
logger.info("[HOOKS] context_gauge: unexpected error: %s", exc)
return {"stdout": "", "exit_code": 0}
@@ -0,0 +1,135 @@
# =================== AIPass ====================
# Name: context_window.py
# Version: 1.0.0
# Description: Shared transcript-usage reader + per-branch compact-window resolver (DPLAN-0253)
# Branch: hooks
# Layer: apps/modules
# Created: 2026-07-20
# Modified: 2026-07-20
# =============================================
"""Reads live context fill from a session transcript and resolves the branch's
auto-compact window — shared by pre_compact_prep.py and context_gauge.py.
Live context size ≈ input_tokens + cache_read_input_tokens + cache_creation_input_tokens
of the most recent assistant turn (proven readable S326)."""
import json
import os
from pathlib import Path
from aipass.cli.apps.modules import err_console
from aipass.prax.apps.modules.logger import system_logger as logger
CONSOLE = err_console
_DEFAULT_WINDOW = 200_000
_TAIL_BYTES = 50_000
def find_branch_dir(cwd: str) -> Path | None:
"""Resolve the branch root (src/aipass/<name>) from a cwd. Same walk as compact.py."""
parts = Path(cwd).parts
for i, part in enumerate(parts):
if part == "aipass" and i > 0 and parts[i - 1] == "src":
branch_dir = Path(*parts[: i + 2])
if branch_dir.is_dir():
return branch_dir
if (Path(cwd) / ".trinity").is_dir():
return Path(cwd)
return None
def read_latest_usage(transcript_path: str, tail_bytes: int = _TAIL_BYTES) -> dict | None:
"""Tail a transcript JSONL and return the most recent assistant message's usage dict."""
if not transcript_path:
return None
path = Path(transcript_path)
try:
size = path.stat().st_size
with path.open("rb") as fh:
if size > tail_bytes:
fh.seek(size - tail_bytes)
chunk = fh.read()
except OSError as exc:
logger.info("[HOOKS] context_window: transcript read failed: %s", exc)
return None
lines = [ln for ln in chunk.decode("utf-8", errors="replace").splitlines() if ln.strip()]
for line in reversed(lines):
try:
entry = json.loads(line)
except json.JSONDecodeError as exc:
logger.info("[HOOKS] context_window: skipping unparsable transcript line: %s", exc)
continue
if entry.get("type") != "assistant":
continue
usage = entry.get("message", {}).get("usage")
if isinstance(usage, dict):
return usage
return None
def context_fill_tokens(usage: dict) -> int:
"""Live context size ≈ input + cache_read + cache_creation of one turn."""
return (
usage.get("input_tokens", 0)
+ usage.get("cache_read_input_tokens", 0)
+ usage.get("cache_creation_input_tokens", 0)
)
def resolve_compact_window(cwd: str) -> int:
"""Resolve the auto-compact window: env var > branch settings.local.json > default.
Mirrors CC's own precedence (env beats settings)."""
env_window = os.environ.get("CLAUDE_CODE_AUTO_COMPACT_WINDOW", "")
if env_window:
try:
return int(env_window)
except ValueError:
logger.info("[HOOKS] context_window: bad env window value %r", env_window)
branch_dir = find_branch_dir(cwd)
if branch_dir is not None:
settings_path = branch_dir / ".claude" / "settings.local.json"
if settings_path.is_file():
try:
data = json.loads(settings_path.read_text(encoding="utf-8"))
window = data.get("autoCompactWindow")
if isinstance(window, int) and window > 0:
return window
except (json.JSONDecodeError, OSError) as exc:
logger.info("[HOOKS] context_window: settings read failed: %s", exc)
return _DEFAULT_WINDOW
# =============================================================================
# MODULE INTERFACE (drone @hooks routing)
# =============================================================================
def print_introspection() -> None:
"""Print context_window config and the resolved window for CWD."""
CONSOLE.print("[bold cyan]context_window[/bold cyan] Module")
CONSOLE.print(f" Default window: {_DEFAULT_WINDOW:,}")
CONSOLE.print(f" Transcript tail read: {_TAIL_BYTES:,} bytes")
window = resolve_compact_window(str(Path.cwd()))
CONSOLE.print(f" Resolved window (CWD): {window:,}")
def handle_command(command: str, args: list) -> bool:
"""Route context_window commands from drone @hooks."""
if command in ("--help", "-h", "help"):
CONSOLE.print("[bold cyan]context_window[/bold cyan] — Transcript usage + compact-window resolver")
CONSOLE.print()
CONSOLE.print(" drone @hooks context_window Show default window, tail size, resolved window for CWD")
return True
if command == "context_window":
if not args:
print_introspection()
return True
return False
@@ -0,0 +1,132 @@
# =================== AIPass ====================
# Name: test_context_gauge.py
# Version: 1.0.0
# Description: Tests for context_gauge prompt handler
# Branch: hooks
# Created: 2026-07-20
# Modified: 2026-07-20
# =============================================
"""Tests for handlers/prompt/context_gauge.py."""
import json
from unittest.mock import patch
import pytest
MODULE = "aipass.hooks.apps.handlers.prompt.context_gauge"
def _write_transcript(path, input_tokens=0, cache_read=0, cache_creation=0):
entry = {
"type": "assistant",
"message": {
"usage": {
"input_tokens": input_tokens,
"cache_read_input_tokens": cache_read,
"cache_creation_input_tokens": cache_creation,
}
},
}
path.write_text(json.dumps(entry), encoding="utf-8")
class TestContextGaugeHandle:
def test_no_transcript_path_is_a_noop(self):
from aipass.hooks.apps.handlers.prompt.context_gauge import handle
result = handle({"session_id": "s1"})
assert result == {"stdout": "", "exit_code": 0}
def test_below_nudge_threshold_is_silent(self, tmp_path, monkeypatch):
from aipass.hooks.apps.handlers.prompt.context_gauge import handle
monkeypatch.setenv("CLAUDE_CODE_AUTO_COMPACT_WINDOW", "200000")
transcript = tmp_path / "t.jsonl"
_write_transcript(transcript, cache_read=50_000)
with patch(f"{MODULE}._GUARD_DIR", tmp_path):
result = handle({"session_id": "s-below", "transcript_path": str(transcript), "cwd": str(tmp_path)})
assert result == {"stdout": "", "exit_code": 0}
def test_fires_nudge_at_80_percent_of_trigger(self, tmp_path, monkeypatch):
from aipass.hooks.apps.handlers.prompt.context_gauge import handle
monkeypatch.setenv("CLAUDE_CODE_AUTO_COMPACT_WINDOW", "200000")
transcript = tmp_path / "t.jsonl"
# trigger = 200000 * 0.9 = 180000; 80% of that = 144000
_write_transcript(transcript, cache_read=145_000)
with patch(f"{MODULE}._GUARD_DIR", tmp_path):
result = handle({"session_id": "s-nudge", "transcript_path": str(transcript), "cwd": str(tmp_path)})
assert result["exit_code"] == 0
assert "CONTEXT GAUGE" in result["stdout"]
assert "run /prep NOW" in result["stdout"]
assert "wrap up the current work item" not in result["stdout"]
def test_fires_escalate_at_95_percent_of_trigger(self, tmp_path, monkeypatch):
from aipass.hooks.apps.handlers.prompt.context_gauge import handle
monkeypatch.setenv("CLAUDE_CODE_AUTO_COMPACT_WINDOW", "200000")
transcript = tmp_path / "t.jsonl"
# 95% of 180000 trigger = 171000
_write_transcript(transcript, cache_read=175_000)
with patch(f"{MODULE}._GUARD_DIR", tmp_path):
result = handle({"session_id": "s-escalate", "transcript_path": str(transcript), "cwd": str(tmp_path)})
assert result["exit_code"] == 0
assert "CONTEXT GAUGE" in result["stdout"]
assert "wrap up the current work item" in result["stdout"]
def test_fires_once_per_threshold_per_session(self, tmp_path, monkeypatch):
from aipass.hooks.apps.handlers.prompt.context_gauge import handle
monkeypatch.setenv("CLAUDE_CODE_AUTO_COMPACT_WINDOW", "200000")
transcript = tmp_path / "t.jsonl"
_write_transcript(transcript, cache_read=145_000)
hook_data = {"session_id": "s-once", "transcript_path": str(transcript), "cwd": str(tmp_path)}
with patch(f"{MODULE}._GUARD_DIR", tmp_path):
first = handle(hook_data)
second = handle(hook_data)
assert "CONTEXT GAUGE" in first["stdout"]
assert second == {"stdout": "", "exit_code": 0}
def test_different_sessions_fire_independently(self, tmp_path, monkeypatch):
from aipass.hooks.apps.handlers.prompt.context_gauge import handle
monkeypatch.setenv("CLAUDE_CODE_AUTO_COMPACT_WINDOW", "200000")
transcript = tmp_path / "t.jsonl"
_write_transcript(transcript, cache_read=145_000)
with patch(f"{MODULE}._GUARD_DIR", tmp_path):
first = handle({"session_id": "s-indep-a", "transcript_path": str(transcript), "cwd": str(tmp_path)})
second = handle({"session_id": "s-indep-b", "transcript_path": str(transcript), "cwd": str(tmp_path)})
assert "CONTEXT GAUGE" in first["stdout"]
assert "CONTEXT GAUGE" in second["stdout"]
def test_missing_usage_is_a_noop(self, tmp_path):
from aipass.hooks.apps.handlers.prompt.context_gauge import handle
transcript = tmp_path / "t.jsonl"
transcript.write_text(json.dumps({"type": "user", "message": {}}), encoding="utf-8")
result = handle({"session_id": "s-nousage", "transcript_path": str(transcript), "cwd": str(tmp_path)})
assert result == {"stdout": "", "exit_code": 0}
def test_never_raises_on_unexpected_error(self, monkeypatch):
from aipass.hooks.apps.handlers.prompt import context_gauge
from aipass.hooks.apps.modules import context_window
def _boom(*_args, **_kwargs):
raise RuntimeError("boom")
monkeypatch.setattr(context_window, "read_latest_usage", _boom)
result = context_gauge.handle({"session_id": "s-err", "transcript_path": "/tmp/x.jsonl"})
assert result == {"stdout": "", "exit_code": 0}
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,144 @@
# =================== AIPass ====================
# Name: test_context_window.py
# Version: 1.0.0
# Description: Tests for shared context_window module
# Branch: hooks
# Created: 2026-07-20
# Modified: 2026-07-20
# =============================================
"""Tests for apps/modules/context_window.py."""
import json
import pytest
class TestFindBranchDir:
def test_finds_branch_dir_from_nested_cwd(self, tmp_path):
from aipass.hooks.apps.modules.context_window import find_branch_dir
branch_dir = tmp_path / "src" / "aipass" / "widget"
nested = branch_dir / "apps" / "handlers"
nested.mkdir(parents=True)
result = find_branch_dir(str(nested))
assert result == branch_dir
def test_falls_back_to_trinity_dir(self, tmp_path):
from aipass.hooks.apps.modules.context_window import find_branch_dir
(tmp_path / ".trinity").mkdir()
result = find_branch_dir(str(tmp_path))
assert result == tmp_path
def test_returns_none_when_unresolvable(self, tmp_path):
from aipass.hooks.apps.modules.context_window import find_branch_dir
result = find_branch_dir(str(tmp_path))
assert result is None
class TestReadLatestUsage:
def test_returns_latest_assistant_usage(self, tmp_path):
from aipass.hooks.apps.modules.context_window import read_latest_usage
transcript = tmp_path / "t.jsonl"
lines = [
json.dumps(
{"type": "assistant", "message": {"usage": {"input_tokens": 1, "cache_read_input_tokens": 100}}}
),
json.dumps({"type": "user", "message": {}}),
json.dumps(
{"type": "assistant", "message": {"usage": {"input_tokens": 2, "cache_read_input_tokens": 200}}}
),
]
transcript.write_text("\n".join(lines), encoding="utf-8")
usage = read_latest_usage(str(transcript))
assert usage == {"input_tokens": 2, "cache_read_input_tokens": 200}
def test_returns_none_for_missing_file(self, tmp_path):
from aipass.hooks.apps.modules.context_window import read_latest_usage
usage = read_latest_usage(str(tmp_path / "nope.jsonl"))
assert usage is None
def test_returns_none_for_empty_path(self):
from aipass.hooks.apps.modules.context_window import read_latest_usage
assert read_latest_usage("") is None
def test_tail_read_skips_earlier_content(self, tmp_path):
"""Only the last ~tail_bytes are read — a partial leading line is tolerated."""
from aipass.hooks.apps.modules.context_window import read_latest_usage
transcript = tmp_path / "t.jsonl"
filler = json.dumps({"type": "assistant", "message": {"usage": {"input_tokens": 1}}}) + "\n"
target = json.dumps({"type": "assistant", "message": {"usage": {"input_tokens": 99}}})
transcript.write_text(filler * 2000 + target, encoding="utf-8")
usage = read_latest_usage(str(transcript), tail_bytes=200)
assert usage == {"input_tokens": 99}
def test_ignores_non_assistant_entries_without_usage(self, tmp_path):
from aipass.hooks.apps.modules.context_window import read_latest_usage
transcript = tmp_path / "t.jsonl"
lines = [
json.dumps({"type": "assistant", "message": {"usage": {"input_tokens": 5}}}),
json.dumps({"type": "assistant", "message": {}}),
"not json at all",
]
transcript.write_text("\n".join(lines), encoding="utf-8")
usage = read_latest_usage(str(transcript))
assert usage == {"input_tokens": 5}
class TestContextFillTokens:
def test_sums_all_three_fields(self):
from aipass.hooks.apps.modules.context_window import context_fill_tokens
usage = {"input_tokens": 1, "cache_read_input_tokens": 2, "cache_creation_input_tokens": 3}
assert context_fill_tokens(usage) == 6
def test_missing_fields_default_to_zero(self):
from aipass.hooks.apps.modules.context_window import context_fill_tokens
assert context_fill_tokens({}) == 0
class TestResolveCompactWindow:
def test_env_var_takes_precedence(self, tmp_path, monkeypatch):
from aipass.hooks.apps.modules.context_window import resolve_compact_window
monkeypatch.setenv("CLAUDE_CODE_AUTO_COMPACT_WINDOW", "350000")
assert resolve_compact_window(str(tmp_path)) == 350000
def test_reads_branch_settings_when_no_env(self, tmp_path, monkeypatch):
from aipass.hooks.apps.modules.context_window import resolve_compact_window
monkeypatch.delenv("CLAUDE_CODE_AUTO_COMPACT_WINDOW", raising=False)
branch_dir = tmp_path / "src" / "aipass" / "widget"
claude_dir = branch_dir / ".claude"
claude_dir.mkdir(parents=True)
(claude_dir / "settings.local.json").write_text(json.dumps({"autoCompactWindow": 350000}), encoding="utf-8")
assert resolve_compact_window(str(branch_dir)) == 350000
def test_defaults_to_200k_when_nothing_found(self, tmp_path, monkeypatch):
from aipass.hooks.apps.modules.context_window import resolve_compact_window
monkeypatch.delenv("CLAUDE_CODE_AUTO_COMPACT_WINDOW", raising=False)
assert resolve_compact_window(str(tmp_path)) == 200_000
def test_bad_env_value_falls_through(self, tmp_path, monkeypatch):
from aipass.hooks.apps.modules.context_window import resolve_compact_window
monkeypatch.setenv("CLAUDE_CODE_AUTO_COMPACT_WINDOW", "not-a-number")
assert resolve_compact_window(str(tmp_path)) == 200_000
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,210 @@
# =================== AIPass ====================
# Name: test_pre_compact_prep.py
# Version: 1.0.0
# Description: Tests for pre_compact_prep lifecycle handler
# Branch: hooks
# Created: 2026-07-20
# Modified: 2026-07-20
# =============================================
"""Tests for handlers/lifecycle/pre_compact_prep.py."""
import json
from unittest.mock import patch
import pytest
MODULE = "aipass.hooks.apps.handlers.lifecycle.pre_compact_prep"
def _make_branch(tmp_path, with_local=True, sessions=None):
branch_dir = tmp_path / "src" / "aipass" / "widget"
branch_dir.mkdir(parents=True)
if with_local:
trinity = branch_dir / ".trinity"
trinity.mkdir()
(trinity / "local.json").write_text(
json.dumps({"sessions": sessions if sessions is not None else []}), encoding="utf-8"
)
return branch_dir
class TestHandle:
def test_stamps_session_entry_and_returns_stdout(self, tmp_path):
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import handle
branch_dir = _make_branch(tmp_path, sessions=[{"number": 3, "date": "2026-01-01", "summary": "old"}])
with patch(f"{MODULE}._find_repo_root", return_value=None):
result = handle({"cwd": str(branch_dir)})
assert result["exit_code"] == 0
assert "AUTO-COMPACT SNAPSHOT" in result["stdout"]
data = json.loads((branch_dir / ".trinity" / "local.json").read_text(encoding="utf-8"))
assert len(data["sessions"]) == 2
newest = data["sessions"][0]
assert newest["number"] == 4
assert newest["status"] == "auto-compact"
assert "AUTO-COMPACT SNAPSHOT" in newest["summary"]
def test_prepends_number_as_max_plus_one(self, tmp_path):
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import handle
branch_dir = _make_branch(
tmp_path,
sessions=[
{"number": 10, "date": "2026-01-01", "summary": "a"},
{"number": 7, "date": "2026-01-01", "summary": "b"},
],
)
with patch(f"{MODULE}._find_repo_root", return_value=None):
handle({"cwd": str(branch_dir)})
data = json.loads((branch_dir / ".trinity" / "local.json").read_text(encoding="utf-8"))
assert data["sessions"][0]["number"] == 11
def test_no_branch_dir_resolved_is_a_noop(self, tmp_path):
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import handle
result = handle({"cwd": str(tmp_path)})
assert result == {"stdout": "", "exit_code": 0}
def test_missing_local_json_skips_write_but_still_returns_snapshot(self, tmp_path):
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import handle
branch_dir = _make_branch(tmp_path, with_local=False)
with patch(f"{MODULE}._find_repo_root", return_value=None):
result = handle({"cwd": str(branch_dir)})
assert result["exit_code"] == 0
assert "AUTO-COMPACT SNAPSHOT" in result["stdout"]
def test_malformed_local_json_never_raises_and_is_untouched(self, tmp_path):
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import handle
branch_dir = tmp_path / "src" / "aipass" / "widget"
trinity = branch_dir / ".trinity"
trinity.mkdir(parents=True)
local_path = trinity / "local.json"
local_path.write_text("{not valid json", encoding="utf-8")
with patch(f"{MODULE}._find_repo_root", return_value=None):
result = handle({"cwd": str(branch_dir)})
assert result["exit_code"] == 0
assert local_path.read_text(encoding="utf-8") == "{not valid json"
def test_sessions_not_a_list_skips_write(self, tmp_path):
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import handle
branch_dir = tmp_path / "src" / "aipass" / "widget"
trinity = branch_dir / ".trinity"
trinity.mkdir(parents=True)
local_path = trinity / "local.json"
local_path.write_text(json.dumps({"sessions": "not-a-list"}), encoding="utf-8")
with patch(f"{MODULE}._find_repo_root", return_value=None):
result = handle({"cwd": str(branch_dir)})
assert result["exit_code"] == 0
data = json.loads(local_path.read_text(encoding="utf-8"))
assert data["sessions"] == "not-a-list"
def test_summary_truncated_to_cap(self, tmp_path):
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import handle
branch_dir = _make_branch(tmp_path, sessions=[])
with patch(f"{MODULE}._find_repo_root", return_value=None):
with patch(f"{MODULE}._build_snapshot", return_value="X" * 500):
handle({"cwd": str(branch_dir)})
data = json.loads((branch_dir / ".trinity" / "local.json").read_text(encoding="utf-8"))
assert len(data["sessions"][0]["summary"]) == 300
def test_never_raises_on_unexpected_error(self):
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import handle
with patch("aipass.hooks.apps.modules.context_window.find_branch_dir", side_effect=RuntimeError("boom")):
result = handle({"cwd": "/tmp"})
assert result == {"stdout": "", "exit_code": 0}
class TestCountOpenPlans:
def test_counts_only_matching_location(self, tmp_path):
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import _count_open_plans
branch_dir = tmp_path / "src" / "aipass" / "widget"
branch_dir.mkdir(parents=True)
fake_plans = [
("0001", {"location": str(branch_dir.resolve())}),
("0002", {"location": "/somewhere/else"}),
("0003", {"location": str(branch_dir.resolve())}),
]
with patch("aipass.flow.apps.handlers.plan.get_open_plans.get_open_plans", return_value=fake_plans):
count = _count_open_plans(branch_dir)
assert count == 2
def test_returns_none_on_import_failure(self, tmp_path):
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import _count_open_plans
branch_dir = tmp_path / "src" / "aipass" / "widget"
branch_dir.mkdir(parents=True)
with patch("aipass.flow.apps.handlers.plan.get_open_plans.get_open_plans", side_effect=RuntimeError("no")):
count = _count_open_plans(branch_dir)
assert count is None
class TestCountActiveDispatchLocks:
def test_counts_lock_files_across_branches(self, tmp_path):
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import _count_active_dispatch_locks
(tmp_path / "src" / "aipass" / "a" / ".ai_mail.local").mkdir(parents=True)
(tmp_path / "src" / "aipass" / "a" / ".ai_mail.local" / ".dispatch.lock").write_text("{}")
(tmp_path / "src" / "aipass" / "b" / ".ai_mail.local").mkdir(parents=True)
registry = {
"branches": [
{"name": "A", "path": "src/aipass/a"},
{"name": "B", "path": "src/aipass/b"},
]
}
(tmp_path / "AIPASS_REGISTRY.json").write_text(json.dumps(registry), encoding="utf-8")
count = _count_active_dispatch_locks(tmp_path)
assert count == 1
def test_returns_none_without_repo_root(self):
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import _count_active_dispatch_locks
assert _count_active_dispatch_locks(None) is None
class TestInboxUnread:
def test_reads_unread_count(self, tmp_path):
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import _inbox_unread
branch_dir = tmp_path / "widget"
mail_dir = branch_dir / ".ai_mail.local"
mail_dir.mkdir(parents=True)
(mail_dir / "inbox.json").write_text(json.dumps({"unread_count": 4}), encoding="utf-8")
assert _inbox_unread(branch_dir) == 4
def test_returns_none_when_missing(self, tmp_path):
from aipass.hooks.apps.handlers.lifecycle.pre_compact_prep import _inbox_unread
assert _inbox_unread(tmp_path / "widget") is None
if __name__ == "__main__":
pytest.main([__file__, "-v"])