From 33a1510cbb41cf78448eb50bbaa35d1d06b2d276 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Mon, 20 Jul 2026 13:13:43 -0700 Subject: [PATCH] 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 --- .aipass/hooks.json | 12 + .claude/provider_manifest.json | 5 +- CHANGELOG.md | 25 ++ .../hooks/.aipass/aipass_local_prompt.md | 10 +- src/aipass/hooks/.seedgo/bypass.json | 172 ++++++------ src/aipass/hooks/README.md | 7 +- .../handlers/lifecycle/pre_compact_prep.py | 246 ++++++++++++++++++ .../apps/handlers/prompt/context_gauge.py | 104 ++++++++ .../hooks/apps/modules/context_window.py | 135 ++++++++++ src/aipass/hooks/tests/test_context_gauge.py | 132 ++++++++++ src/aipass/hooks/tests/test_context_window.py | 144 ++++++++++ .../hooks/tests/test_pre_compact_prep.py | 210 +++++++++++++++ 12 files changed, 1118 insertions(+), 84 deletions(-) create mode 100644 src/aipass/hooks/apps/handlers/lifecycle/pre_compact_prep.py create mode 100644 src/aipass/hooks/apps/handlers/prompt/context_gauge.py create mode 100644 src/aipass/hooks/apps/modules/context_window.py create mode 100644 src/aipass/hooks/tests/test_context_gauge.py create mode 100644 src/aipass/hooks/tests/test_context_window.py create mode 100644 src/aipass/hooks/tests/test_pre_compact_prep.py diff --git a/.aipass/hooks.json b/.aipass/hooks.json index 566a6c17..ac219000 100644 --- a/.aipass/hooks.json +++ b/.aipass/hooks.json @@ -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 } }, diff --git a/.claude/provider_manifest.json b/.claude/provider_manifest.json index 8e3eda28..f6cfa660 100644 --- a/.claude/provider_manifest.json +++ b/.claude/provider_manifest.json @@ -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}}", diff --git a/CHANGELOG.md b/CHANGELOG.md index f041f713..3d732f1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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): diff --git a/src/aipass/hooks/.aipass/aipass_local_prompt.md b/src/aipass/hooks/.aipass/aipass_local_prompt.md index 6c398ba5..ba1d8ec4 100644 --- a/src/aipass/hooks/.aipass/aipass_local_prompt.md +++ b/src/aipass/hooks/.aipass/aipass_local_prompt.md @@ -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. diff --git a/src/aipass/hooks/.seedgo/bypass.json b/src/aipass/hooks/.seedgo/bypass.json index 712caaa2..c5c1b7e8 100644 --- a/src/aipass/hooks/.seedgo/bypass.json +++ b/src/aipass/hooks/.seedgo/bypass.json @@ -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." } } diff --git a/src/aipass/hooks/README.md b/src/aipass/hooks/README.md index ff7fe010..538d2d52 100644 --- a/src/aipass/hooks/README.md +++ b/src/aipass/hooks/README.md @@ -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/.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 diff --git a/src/aipass/hooks/apps/handlers/lifecycle/pre_compact_prep.py b/src/aipass/hooks/apps/handlers/lifecycle/pre_compact_prep.py new file mode 100644 index 00000000..c5829725 --- /dev/null +++ b/src/aipass/hooks/apps/handlers/lifecycle/pre_compact_prep.py @@ -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} diff --git a/src/aipass/hooks/apps/handlers/prompt/context_gauge.py b/src/aipass/hooks/apps/handlers/prompt/context_gauge.py new file mode 100644 index 00000000..b0bdfdbb --- /dev/null +++ b/src/aipass/hooks/apps/handlers/prompt/context_gauge.py @@ -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} diff --git a/src/aipass/hooks/apps/modules/context_window.py b/src/aipass/hooks/apps/modules/context_window.py new file mode 100644 index 00000000..b2256c84 --- /dev/null +++ b/src/aipass/hooks/apps/modules/context_window.py @@ -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/) 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 diff --git a/src/aipass/hooks/tests/test_context_gauge.py b/src/aipass/hooks/tests/test_context_gauge.py new file mode 100644 index 00000000..7a018cad --- /dev/null +++ b/src/aipass/hooks/tests/test_context_gauge.py @@ -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"]) diff --git a/src/aipass/hooks/tests/test_context_window.py b/src/aipass/hooks/tests/test_context_window.py new file mode 100644 index 00000000..458c2db0 --- /dev/null +++ b/src/aipass/hooks/tests/test_context_window.py @@ -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"]) diff --git a/src/aipass/hooks/tests/test_pre_compact_prep.py b/src/aipass/hooks/tests/test_pre_compact_prep.py new file mode 100644 index 00000000..79d76f4d --- /dev/null +++ b/src/aipass/hooks/tests/test_pre_compact_prep.py @@ -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"])