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:
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
Reference in New Issue
Block a user