fix(hooks): cadence separate-process race + action-gated sound + auto_fix diagnostics regression (DPLAN-0200, FPLAN-0249)
Cadence redo — verified against the live execution model, not unit tests: - Counter now advances exactly once per real turn (mtime debounce + transcript-size token + flock). Fixes the separate-process leapfrog where global/branch loaders double-incremented and fired erratically. - Structured [HOOKS] cadence fired|skipped logging; prax monitor renders hook events distinctly for live visibility. - Action-gated sound: handlers return a 'sound' key the engine plays only on real action — skipped loaders are silent (no more false piper every turn). - Fixed auto_fix.py: leftover speak() NameError (swallowed by broad except) meant diagnostics silently never ran on any edit. Removed; sound moved to the error path. - Tests rewritten to model separate-process execution (leapfrog regression test added); sound assertions across all refactored handlers. 438 pass. prax: hook fire/skip event rendering in the live monitor. 913 pass. README: hardcoded metrics (version/tests/PRs/standards) -> live PyPI+codecov badges and qualitative wording; killed the 33-vs-36 drift. CHANGELOG W24. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,40 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format
|
||||
|
||||
## [2026.W24] - 2026-06-08
|
||||
|
||||
### Added
|
||||
|
||||
- **Prompt-injection cadence — fire the big loaders every Nth turn.** The global
|
||||
and branch prompts are large and were re-injected on *every* turn even though a
|
||||
prior copy stays in the conversation. They now fire together every 5th turn
|
||||
(config-tunable via `hooks_json/custom_config/cadence_config.json`), with a
|
||||
per-session turn counter that resets on a new session and after compaction so
|
||||
context is always rebuilt when it's actually needed. Identity and the mail flag
|
||||
stay every-turn (tiny, want freshness). Cuts recurring per-turn context cost.
|
||||
- **Hook fire/skip observability.** Cadence emits a structured
|
||||
`[HOOKS] cadence fired|skipped loader= turn= period= offset=` line; the prax
|
||||
monitor renders hook events distinctly so the cadence is visible live.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Action-gated hook sound.** Piper now speaks only when a hook actually *does*
|
||||
something — handlers return a `sound` key the engine plays, instead of
|
||||
announcing on every invocation. Skipped loaders are silent. Quieter and honest.
|
||||
- **README: hardcoded metrics → live badges + qualitative.** Version is now a
|
||||
live PyPI badge, test/PR counts replaced with a codecov coverage badge (75%
|
||||
minimum) and qualitative wording — no more stale numbers to hand-maintain.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Cadence counter separate-process race.** Each `UserPromptSubmit` hook runs as
|
||||
its own OS process, so a module-level turn cache double-incremented and the
|
||||
loaders leapfrogged (firing erratically instead of together). Fixed with an
|
||||
mtime debounce + transcript-size token + `flock` so the counter advances exactly
|
||||
once per real turn, verified against the live execution model.
|
||||
- **`auto_fix` ran no diagnostics.** A leftover `speak()` call (its import removed
|
||||
in the sound refactor) raised `NameError` on every edit, swallowed by the
|
||||
handler's broad `except` — so auto-fix silently surfaced nothing on any
|
||||
`.py`/`.json` edit. Removed the dead call; diagnostics run again.
|
||||
|
||||
### Security
|
||||
|
||||
- **Least-privilege token on the `e2e-wheel` workflow.** `e2e-wheel.yml` was the
|
||||
|
||||
@@ -47,7 +47,7 @@ my-project/
|
||||
├── .aipass/ # Project config + prompts
|
||||
├── .claude/ # Hooks (injected automatically)
|
||||
├── src/my_project/
|
||||
│ └── my-agent/
|
||||
│ └── my_agent/
|
||||
│ ├── .trinity/ # Identity + memory (3 JSON files)
|
||||
│ ├── .ai_mail.local/ # Local mailbox
|
||||
│ ├── apps/ # Your agent's code
|
||||
@@ -91,7 +91,7 @@ That's it. Your agent has identity, memory, a mailbox, and access to every AIPas
|
||||
|
||||
```bash
|
||||
aipass init # Just the scaffold (no guided setup)
|
||||
aipass init agent my-agent # Add another agent
|
||||
aipass init agent my_agent # Add another agent
|
||||
aipass doctor # Check system health
|
||||
```
|
||||
|
||||
@@ -112,8 +112,8 @@ claude # Talk to the orchestrator
|
||||
|
||||
```bash
|
||||
# Things you can do:
|
||||
aipass doctor # Check system health (15+ checks)
|
||||
drone @seedgo audit aipass # Run 36 quality checks across all agents
|
||||
aipass doctor # Check system health
|
||||
drone @seedgo audit aipass # Run automated quality checks across all agents
|
||||
drone @flow create . "Add user auth" # Create a work plan
|
||||
drone @ai_mail dispatch @agent "Sub" "Body" # Send task + wake an agent
|
||||
```
|
||||
@@ -141,7 +141,7 @@ drone @branch command [args] # Every agent, every task. Drone handles routing
|
||||
```
|
||||
|
||||
```bash
|
||||
drone @seedgo audit my-project # Run quality checks on everything
|
||||
drone @seedgo audit my_project # Run quality checks on everything
|
||||
drone @flow create . "Refactor auth module" # Create a work plan
|
||||
drone @ai_mail dispatch @agent "Archive old sessions" "Find sessions older than 30 days"
|
||||
```
|
||||
@@ -161,7 +161,7 @@ AIPass ships with 13 core agents that maintain and develop the framework itself
|
||||
devpulse (orchestrator)
|
||||
├── aipass — concierge + onboarding (aipass init, doctor, profile)
|
||||
├── drone — command routing + @agent resolution
|
||||
├── seedgo — 36 automated quality standards
|
||||
├── seedgo — automated quality standards
|
||||
├── prax — real-time monitoring across all agents
|
||||
├── ai_mail — agent-to-agent communication + task dispatch
|
||||
├── flow — plan lifecycle, templates, auto-archival
|
||||
@@ -195,9 +195,9 @@ These agents work on the **same filesystem, same project, same time** — no san
|
||||
|
||||
| Agent | Role |
|
||||
|-------|------|
|
||||
| [**seedgo**](src/aipass/seedgo/README.md) | 36 automated quality standards, enforced across all agents |
|
||||
| [**seedgo**](src/aipass/seedgo/README.md) | Automated quality standards, enforced across all agents |
|
||||
| [**prax**](src/aipass/prax/README.md) | Real-time monitoring, logs, dashboards |
|
||||
| [**flow**](src/aipass/flow/README.md) | Plan lifecycle — 6 template types, auto-archival, vector verification |
|
||||
| [**flow**](src/aipass/flow/README.md) | Plan lifecycle — multiple template types, auto-archival, vector verification |
|
||||
| [**hooks**](src/aipass/hooks/README.md) | Hook engine — per-project config, sound control, event dispatch |
|
||||
| [**trigger**](src/aipass/trigger/README.md) | Event-driven automation + self-healing |
|
||||
| [**cli**](src/aipass/cli/README.md) | Terminal formatting and rich output |
|
||||
@@ -212,7 +212,7 @@ AIPass is built and tested with **Claude Code** on Linux/WSL.
|
||||
|
||||
| CLI | Autonomous Mode | Status |
|
||||
|-----|----------------|--------|
|
||||
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude -p "prompt" --permission-mode bypassPermissions` | Fully tested |
|
||||
| [Claude Code](https://code.claude.com/docs) | `claude -p "prompt" --permission-mode bypassPermissions` | Fully tested |
|
||||
| [Codex](https://github.com/openai/codex) | `codex exec "prompt" --dangerously-bypass-approvals-and-sandbox` | Experimental |
|
||||
|
||||
setup.sh auto-detects which CLIs are installed and configures hooks for each.
|
||||
@@ -225,11 +225,11 @@ setup.sh auto-detects which CLIs are installed and configures hooks for each.
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Version | 2.4.0 |
|
||||
| Version | [](https://pypi.org/project/aipass/) |
|
||||
| Agents | 13 core + user-created |
|
||||
| Quality standards | 36 automated checks |
|
||||
| Tests | 8,400+ (across all agents) |
|
||||
| PRs merged | 600+ (human-AI collaboration) |
|
||||
| Quality | Automated standards enforced across every agent |
|
||||
| Coverage | [](https://codecov.io/gh/AIOSAI/AIPass) — 75% minimum, CI-gated |
|
||||
| Tests | Extensive — every agent ships its own suite |
|
||||
|
||||
Each agent documents its own operational status in its branch README — what works, what doesn't, and why.
|
||||
|
||||
@@ -238,7 +238,7 @@ Each agent documents its own operational status in its branch README — what wo
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
||||
- [Claude Code](https://code.claude.com/docs)
|
||||
- Linux, macOS, or WSL (all CI-tested)
|
||||
- `sudo` access optional (for `/usr/local/bin` symlinks — falls back to `~/.local/bin` without sudo)
|
||||
- API keys optional (OpenRouter/OpenAI — for optional add-on agents)
|
||||
|
||||
@@ -284,7 +284,9 @@ def test_send_reply_multiline_body_preserved(tmp_path):
|
||||
deliver_calls.append((to_branch, email_data))
|
||||
return (True, "")
|
||||
|
||||
multiline_body = "Investigation: cadence findings\n\nDetails:\n1. First finding\n2. Second finding\n3. Third finding"
|
||||
multiline_body = (
|
||||
"Investigation: cadence findings\n\nDetails:\n1. First finding\n2. Second finding\n3. Third finding"
|
||||
)
|
||||
|
||||
with (
|
||||
patch(_PATCH_BRANCH_DETECTION, return_value=sender_info),
|
||||
|
||||
@@ -16,7 +16,6 @@ import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.hooks.apps.sound import speak
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
|
||||
@@ -321,8 +320,6 @@ def handle(hook_data: dict) -> dict:
|
||||
if ext in SKIP_EXTENSIONS:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
speak("auto fix diagnostics")
|
||||
|
||||
errors: list[str] = []
|
||||
|
||||
if file_path.endswith(".py"):
|
||||
@@ -358,7 +355,7 @@ def handle(hook_data: dict) -> dict:
|
||||
},
|
||||
"systemMessage": f"[AUTO-FIX] {len(errors)} error(s) — fix before continuing",
|
||||
}
|
||||
return {"stdout": json.dumps(result), "exit_code": 0}
|
||||
return {"stdout": json.dumps(result), "exit_code": 0, "sound": "auto fix diagnostics"}
|
||||
|
||||
result = {"systemMessage": "[diagnostics] ok"}
|
||||
return {"stdout": json.dumps(result), "exit_code": 0}
|
||||
|
||||
@@ -15,7 +15,6 @@ import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.hooks.apps.sound import speak
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
|
||||
@@ -82,8 +81,6 @@ def _get_git_info() -> str | None:
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
"""Inject live branch state for post-compact recovery."""
|
||||
speak("pre compact")
|
||||
|
||||
try:
|
||||
import importlib
|
||||
|
||||
@@ -130,7 +127,7 @@ def handle(hook_data: dict) -> dict:
|
||||
"- Match the conversation tone from before compaction"
|
||||
)
|
||||
|
||||
return {"stdout": "\n\n".join(sections), "exit_code": 0}
|
||||
return {"stdout": "\n\n".join(sections), "exit_code": 0, "sound": "pre compact"}
|
||||
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] compact: unexpected error: %s", exc)
|
||||
|
||||
@@ -15,7 +15,6 @@ import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.hooks.apps.sound import speak
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
|
||||
@@ -129,10 +128,8 @@ def _run_rollover(repo_root: Path) -> tuple[bool, str]:
|
||||
return False, str(exc)
|
||||
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
def handle(hook_data: dict) -> dict: # noqa: ARG001
|
||||
"""Check memory files for overflow and trigger rollover if needed."""
|
||||
speak("pre compact rollover")
|
||||
|
||||
try:
|
||||
repo_root = _find_repo_root()
|
||||
if not repo_root:
|
||||
@@ -151,7 +148,7 @@ def handle(hook_data: dict) -> dict:
|
||||
else:
|
||||
logger.info("[HOOKS] rollover: failed — %s", output[:200])
|
||||
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
return {"stdout": "", "exit_code": 0, "sound": "pre compact rollover"}
|
||||
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] rollover: unexpected error: %s", exc)
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.hooks.apps.sound import speak
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
|
||||
@@ -30,13 +29,11 @@ def _find_branch_root(cwd: str) -> Path | None:
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
"""Load branch prompt and private integration prompts."""
|
||||
speak("branch prompt")
|
||||
|
||||
try:
|
||||
import importlib
|
||||
|
||||
cadence = importlib.import_module("aipass.hooks.apps.modules.cadence")
|
||||
if not cadence.should_fire("branch"):
|
||||
if not cadence.should_fire("branch", hook_data):
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] branch_loader: cadence check failed, firing anyway: %s", exc)
|
||||
@@ -63,7 +60,7 @@ def handle(hook_data: dict) -> dict:
|
||||
if not parts:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
return {"stdout": "\n".join(parts), "exit_code": 0}
|
||||
return {"stdout": "\n".join(parts), "exit_code": 0, "sound": "branch prompt"}
|
||||
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] branch_loader: unexpected error: %s", exc)
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.hooks.apps.sound import speak
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
|
||||
@@ -31,13 +30,11 @@ def _find_project_prompt() -> Path | None:
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
"""Load global prompt — project-local if outside AIPass, AIPass-internal if inside."""
|
||||
speak("global prompt")
|
||||
|
||||
try:
|
||||
import importlib
|
||||
|
||||
cadence = importlib.import_module("aipass.hooks.apps.modules.cadence")
|
||||
if not cadence.should_fire("global"):
|
||||
if not cadence.should_fire("global", hook_data):
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] global_loader: cadence check failed, firing anyway: %s", exc)
|
||||
@@ -55,7 +52,7 @@ def handle(hook_data: dict) -> dict:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
content = prompt_file.read_text(encoding="utf-8")
|
||||
return {"stdout": content, "exit_code": 0}
|
||||
return {"stdout": content, "exit_code": 0, "sound": "global prompt"}
|
||||
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] global_loader: unexpected error: %s", exc)
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.hooks.apps.sound import speak
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
|
||||
@@ -69,8 +68,6 @@ def _format_identity(data: dict) -> str:
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
"""Inject branch identity from passport.json into prompt context."""
|
||||
speak("identity")
|
||||
|
||||
try:
|
||||
cwd = hook_data.get("cwd", "") or str(Path.cwd())
|
||||
passport = _find_passport(cwd)
|
||||
@@ -82,7 +79,7 @@ def handle(hook_data: dict) -> dict:
|
||||
if not output:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
return {"stdout": f"\n{output}", "exit_code": 0}
|
||||
return {"stdout": f"\n{output}", "exit_code": 0, "sound": "identity"}
|
||||
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] identity: unexpected error: %s", exc)
|
||||
|
||||
@@ -14,7 +14,6 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.hooks.apps.sound import speak
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
|
||||
@@ -50,8 +49,6 @@ def handle(hook_data: dict) -> dict:
|
||||
Returns:
|
||||
Result dict with stdout (block JSON or empty) and exit_code.
|
||||
"""
|
||||
speak("edit gate")
|
||||
|
||||
try:
|
||||
tool_name = hook_data.get("tool_name", "")
|
||||
tool_input = hook_data.get("tool_input", {})
|
||||
@@ -66,7 +63,7 @@ def handle(hook_data: dict) -> dict:
|
||||
fp = Path(file_path)
|
||||
if fp.name == "inbox.json" and ".ai_mail.local" in fp.parts:
|
||||
reason = 'Direct writes to inbox.json are blocked.\nUse: drone @ai_mail email @<branch> "Subject" "Body"'
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2}
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2, "sound": "edit gate"}
|
||||
|
||||
cwd = hook_data.get("cwd", "") or os.getcwd()
|
||||
package = _get_package_from_cwd(cwd)
|
||||
@@ -80,7 +77,11 @@ def handle(hook_data: dict) -> dict:
|
||||
f"Dispatched agent confined to own branch: '{cwd_branch}' "
|
||||
f"cannot write to '{target_branch}' in daemon mode."
|
||||
)
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2}
|
||||
return {
|
||||
"stdout": json.dumps({"decision": "block", "reason": reason}),
|
||||
"exit_code": 2,
|
||||
"sound": "edit gate",
|
||||
}
|
||||
repo_root = None
|
||||
for parent in Path(cwd).parents:
|
||||
if (parent / ".git").exists():
|
||||
@@ -91,7 +92,11 @@ def handle(hook_data: dict) -> dict:
|
||||
resolved = str(fp.resolve()) if not fp.is_absolute() else str(fp)
|
||||
if not resolved.startswith(allowed_prefix):
|
||||
reason = f"Dispatched agent restricted to {allowed_prefix}. Cannot write to: {file_path}"
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2}
|
||||
return {
|
||||
"stdout": json.dumps({"decision": "block", "reason": reason}),
|
||||
"exit_code": 2,
|
||||
"sound": "edit gate",
|
||||
}
|
||||
|
||||
target_branch = _get_branch(str(fp.resolve()) if not fp.is_absolute() else str(fp), package)
|
||||
|
||||
@@ -101,7 +106,11 @@ def handle(hook_data: dict) -> dict:
|
||||
f"Cross-branch write blocked: '{cwd_branch}' cannot write to '{target_branch}'.\n"
|
||||
f"Trusted cross-writers: {', '.join(TRUSTED_CROSS_WRITERS)}"
|
||||
)
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2}
|
||||
return {
|
||||
"stdout": json.dumps({"decision": "block", "reason": reason}),
|
||||
"exit_code": 2,
|
||||
"sound": "edit gate",
|
||||
}
|
||||
|
||||
if not file_path.endswith(".py"):
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
@@ -140,7 +149,11 @@ def handle(hook_data: dict) -> dict:
|
||||
|
||||
error_summary = "\n".join(f" L{e['line']}: {e['message']}" for e in errors[:5])
|
||||
reason = f"Fix {len(errors)} error(s) in {Path(errored_file).name} before editing other files:\n{error_summary}"
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2}
|
||||
return {
|
||||
"stdout": json.dumps({"decision": "block", "reason": reason}),
|
||||
"exit_code": 2,
|
||||
"sound": "edit gate",
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] edit_gate: unexpected error (allowing): %s", exc)
|
||||
|
||||
@@ -15,7 +15,6 @@ import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.hooks.apps.sound import speak
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
|
||||
@@ -133,7 +132,7 @@ def _all_git_reads(scan: str) -> bool:
|
||||
|
||||
|
||||
def _block(reason: str) -> dict:
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2}
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2, "sound": "git gate"}
|
||||
|
||||
|
||||
def _check_bash(tool_input: dict) -> dict:
|
||||
@@ -170,8 +169,6 @@ def handle(hook_data: dict) -> dict:
|
||||
Returns:
|
||||
Result dict with stdout (block JSON or empty) and exit_code.
|
||||
"""
|
||||
speak("git gate")
|
||||
|
||||
try:
|
||||
tool_name = hook_data.get("tool_name", "")
|
||||
tool_input = hook_data.get("tool_input", {})
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
from aipass.hooks.apps.sound import speak
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
|
||||
@@ -28,7 +27,7 @@ _BLOCK_ALLOW = {"stdout": "", "exit_code": 0}
|
||||
|
||||
|
||||
def _block(reason: str) -> dict:
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2}
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2, "sound": "rm gate"}
|
||||
|
||||
|
||||
def _strip_quotes(cmd: str) -> str:
|
||||
@@ -84,8 +83,6 @@ def handle(hook_data: dict) -> dict:
|
||||
Returns:
|
||||
Result dict with stdout (block JSON or empty) and exit_code.
|
||||
"""
|
||||
speak("rm gate")
|
||||
|
||||
try:
|
||||
tool_name = hook_data.get("tool_name", "")
|
||||
if tool_name != "Bash":
|
||||
|
||||
@@ -15,14 +15,13 @@ import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.hooks.apps.sound import speak
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
_ALLOW = {"stdout": "", "exit_code": 0}
|
||||
|
||||
|
||||
def _block(reason: str) -> dict:
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2}
|
||||
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2, "sound": "subagent gate"}
|
||||
|
||||
|
||||
def _get_package_from_cwd(cwd: str) -> str:
|
||||
@@ -152,8 +151,6 @@ def _check_hook_readme_accountability(cwd: str, repo_root: Path) -> str | None:
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
"""Check modified files against seedgo standards on subagent stop."""
|
||||
speak("subagent stop gate")
|
||||
|
||||
try:
|
||||
cwd = hook_data.get("cwd", "") or os.getcwd()
|
||||
repo_root = _find_repo_root(cwd)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: cadence.py
|
||||
# Version: 1.0.0
|
||||
# Version: 2.0.0
|
||||
# Description: Per-session turn counter for prompt injection cadence (DPLAN-0200)
|
||||
# Branch: hooks
|
||||
# Layer: apps/modules
|
||||
@@ -8,20 +8,33 @@
|
||||
# Modified: 2026-06-08
|
||||
# =============================================
|
||||
|
||||
"""Turn counter for prompt injection cadence — fires loaders every Nth turn."""
|
||||
"""Turn counter for prompt injection cadence — fires loaders every Nth turn.
|
||||
|
||||
Multi-process safe: each UserPromptSubmit hook runs as a separate OS process.
|
||||
Uses fcntl.flock + mtime debounce + per-turn token to ensure the counter
|
||||
advances exactly once per real user turn.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.cli.apps.modules import err_console
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError:
|
||||
fcntl = None # type: ignore[assignment]
|
||||
logger.info("[HOOKS] cadence: fcntl unavailable (Windows)")
|
||||
|
||||
CONSOLE = err_console
|
||||
|
||||
_GUARD_DIR = Path("/tmp")
|
||||
_BRANCH_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
_CONFIG_PATH = _BRANCH_ROOT / "hooks_json" / "custom_config" / "cadence_config.json"
|
||||
_DEBOUNCE_S = 2.0
|
||||
|
||||
HELP_COMMANDS = [
|
||||
("cadence", "Show prompt injection cadence config and state"),
|
||||
@@ -77,8 +90,61 @@ def _state_path() -> Path | None:
|
||||
return _GUARD_DIR / f"aipass-cadence-{session_id}.json"
|
||||
|
||||
|
||||
def _load_and_increment() -> int:
|
||||
"""Load turn counter from /tmp, increment, write back. Cached per-process."""
|
||||
def _get_turn_token(hook_data: dict) -> int:
|
||||
"""Per-turn token from transcript_path size (monotonic, identical across siblings)."""
|
||||
tp = hook_data.get("transcript_path", "")
|
||||
if not tp:
|
||||
return 0
|
||||
try:
|
||||
return os.path.getsize(tp)
|
||||
except OSError as exc:
|
||||
logger.info("[HOOKS] cadence: transcript stat failed: %s", exc)
|
||||
return 0
|
||||
|
||||
|
||||
def _lock(fd) -> None:
|
||||
"""Acquire exclusive lock (no-op on Windows)."""
|
||||
if fcntl is not None:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX)
|
||||
|
||||
|
||||
def _unlock(fd) -> None:
|
||||
"""Release exclusive lock (no-op on Windows)."""
|
||||
if fcntl is not None:
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def _close_fd(fd) -> None:
|
||||
"""Unlock and close a file descriptor safely."""
|
||||
try:
|
||||
_unlock(fd)
|
||||
fd.close()
|
||||
except OSError as exc:
|
||||
logger.info("[HOOKS] cadence: fd cleanup failed: %s", exc)
|
||||
|
||||
|
||||
def _mtime_age(fd) -> float:
|
||||
"""Seconds since file was last modified, via the open fd."""
|
||||
try:
|
||||
return time.time() - os.fstat(fd.fileno()).st_mtime
|
||||
except OSError as exc:
|
||||
logger.info("[HOOKS] cadence: fstat failed, assuming stale: %s", exc)
|
||||
return _DEBOUNCE_S + 1
|
||||
|
||||
|
||||
def _should_increment(stored_turn: int, stored_token: int, token: int, fd) -> bool:
|
||||
"""Decide whether to increment the counter. Extracted for nesting depth."""
|
||||
if stored_turn < 0:
|
||||
return True
|
||||
if _mtime_age(fd) < _DEBOUNCE_S:
|
||||
return False
|
||||
if token == stored_token and token != 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _load_and_increment(hook_data: dict) -> int:
|
||||
"""Load turn counter, increment exactly once per real turn. Multi-process safe."""
|
||||
global _turn
|
||||
if _turn is not None:
|
||||
return _turn
|
||||
@@ -88,25 +154,42 @@ def _load_and_increment() -> int:
|
||||
_turn = 0
|
||||
return 0
|
||||
|
||||
count = 0
|
||||
if path.exists():
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
count = data.get("turn", 0) + 1
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.info("[HOOKS] cadence: state read failed, resetting: %s", exc)
|
||||
count = 0
|
||||
token = _get_turn_token(hook_data)
|
||||
fd = None
|
||||
|
||||
try:
|
||||
path.write_text(json.dumps({"turn": count}), encoding="utf-8")
|
||||
except OSError as exc:
|
||||
logger.info("[HOOKS] cadence: state write failed: %s", exc)
|
||||
fd = open(path, "a+") # noqa: SIM115
|
||||
_lock(fd)
|
||||
fd.seek(0)
|
||||
content = fd.read()
|
||||
|
||||
_turn = count
|
||||
return count
|
||||
data = json.loads(content) if content.strip() else {}
|
||||
stored_turn = data.get("turn", -1)
|
||||
stored_token = data.get("token", -1)
|
||||
|
||||
if _should_increment(stored_turn, stored_token, token, fd):
|
||||
new_turn = max(stored_turn + 1, 0)
|
||||
fd.seek(0)
|
||||
fd.truncate()
|
||||
fd.write(json.dumps({"turn": new_turn, "token": token}))
|
||||
fd.flush()
|
||||
else:
|
||||
new_turn = stored_turn
|
||||
|
||||
_close_fd(fd)
|
||||
fd = None
|
||||
_turn = new_turn
|
||||
return new_turn
|
||||
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
logger.info("[HOOKS] cadence: state access failed: %s", exc)
|
||||
if fd is not None:
|
||||
_close_fd(fd)
|
||||
_turn = 0
|
||||
return 0
|
||||
|
||||
|
||||
def should_fire(loader_name: str) -> bool:
|
||||
def should_fire(loader_name: str, hook_data: dict | None = None) -> bool:
|
||||
"""Check if a loader should fire this turn. Always True on turn 0 or if cadence disabled."""
|
||||
config = _load_config()
|
||||
|
||||
@@ -120,12 +203,24 @@ def should_fire(loader_name: str) -> bool:
|
||||
loader_config = config.get("loaders", {}).get(loader_name, {})
|
||||
offset = loader_config.get("offset", 0)
|
||||
|
||||
turn = _load_and_increment()
|
||||
turn = _load_and_increment(hook_data or {})
|
||||
|
||||
if turn == 0:
|
||||
return True
|
||||
fired = turn == 0 or (turn % period) == offset
|
||||
|
||||
return (turn % period) == offset
|
||||
session_id = os.environ.get("CLAUDE_CODE_SESSION_ID", "")
|
||||
session_short = session_id[:8] if session_id else "none"
|
||||
action = "fired" if fired else "skipped"
|
||||
logger.info(
|
||||
"[HOOKS] cadence %s loader=%s turn=%d period=%d offset=%d session=%s",
|
||||
action,
|
||||
loader_name,
|
||||
turn,
|
||||
period,
|
||||
offset,
|
||||
session_short,
|
||||
)
|
||||
|
||||
return fired
|
||||
|
||||
|
||||
def reset_counter() -> None:
|
||||
@@ -133,11 +228,22 @@ def reset_counter() -> None:
|
||||
path = _state_path()
|
||||
if path is None:
|
||||
return
|
||||
|
||||
fd = None
|
||||
try:
|
||||
path.write_text(json.dumps({"turn": -1}), encoding="utf-8")
|
||||
fd = open(path, "a+") # noqa: SIM115
|
||||
_lock(fd)
|
||||
fd.seek(0)
|
||||
fd.truncate()
|
||||
fd.write(json.dumps({"turn": -1, "token": -1}))
|
||||
fd.flush()
|
||||
_close_fd(fd)
|
||||
fd = None
|
||||
logger.info("[HOOKS] cadence: counter reset for post-compact re-injection")
|
||||
except OSError as exc:
|
||||
logger.info("[HOOKS] cadence: reset write failed: %s", exc)
|
||||
if fd is not None:
|
||||
_close_fd(fd)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -72,6 +72,7 @@ def _run_handler(handler_path: str, hook_data: dict) -> dict:
|
||||
return {
|
||||
"exit_code": result.get("exit_code", 0),
|
||||
"stdout": result.get("stdout", ""),
|
||||
"sound": result.get("sound", ""),
|
||||
"stderr": "",
|
||||
"elapsed_ms": round(elapsed_ms, 1),
|
||||
}
|
||||
@@ -166,6 +167,14 @@ def dispatch(event_type: str, stdin_data: str, config: dict) -> str:
|
||||
}
|
||||
)
|
||||
|
||||
if result.get("sound"):
|
||||
try:
|
||||
from aipass.hooks.apps.sound import speak
|
||||
|
||||
speak(result["sound"])
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] sound playback failed for %s.%s: %s", event_type, hook_name, exc)
|
||||
|
||||
# Exit code 2: crash vs intentional block
|
||||
if result["exit_code"] == 2:
|
||||
is_intentional_block = False
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Cadence REDO brief — DPLAN-0200 WS-B (FPLAN-0249 reopen)
|
||||
|
||||
Your cadence build passed 435 tests but is **BROKEN in the live environment** — confirmed by direct observation + 3 research sub-agents. The 435 tests lied because they modeled the **wrong execution model**. Fix-forward: commit 2bccf03 stays, build on top, no history surgery.
|
||||
|
||||
## ROOT CAUSE (confirmed)
|
||||
Each loader runs as a **separate OS process**. `settings.json` registers distinct commands: `claude.py UserPromptSubmit:global_prompt`, `:branch_prompt`, `:identity_injector`, `:email_notification`, `:auto_process` — 5 separate python subprocesses. The module-level `_turn` cache assumed **sequential single-process** dispatch (cadence_investigation.md:28 and :99 flagged this as THE fragility "if Claude Code ever parallelizes" — it was ALREADY true). So `global` increments the /tmp counter to N, `branch` (separate process) to N+1 → counter races +2/turn → the two loaders **leapfrog** → firing is erratic, never "both every 5th". Live proof: counter 33 → 35 → 37 across single turns.
|
||||
|
||||
## FIX 1 — DEDUP THE COUNTER (keystone)
|
||||
The counter must advance **exactly once per real user turn** regardless of how many sibling processes call it.
|
||||
|
||||
- **PRIMARY — mtime/recency debounce** on the /tmp state file. In `_load_and_increment`, before incrementing, `stat` the file; if last-modified < ~2000 ms ago, treat as the SAME turn → re-read current turn, return WITHOUT incrementing. (The 5 siblings spawn near-simultaneously; the first increments, the rest reuse.)
|
||||
- **BACKSTOP — per-turn token** = `transcript_path` SIZE / line-count (it grows by one entry per turn, identical across all siblings, monotonic). Only increment if BOTH the debounce window elapsed AND the token changed. Kills the two realistic failure modes (pathologically fast turn; identical-prompt collision).
|
||||
- **REQUIRED — fcntl.flock** around the read-modify-write. The siblings are truly simultaneous; without the lock, two can both read old-mtime and both increment.
|
||||
- **SPECIAL-CASE turn < 0** (post-compact reset): ALWAYS increment — don't let the debounce swallow the post-compact turn-0 all-fire guarantee.
|
||||
- **STDIN FIELDS (corrected — the doc is WRONG):** UserPromptSubmit stdin = `session_id`, `transcript_path`, `cwd`, `hook_event_name`, `prompt`. The field is `prompt`, NOT `user_prompt`; `session_id` IS present. Thread the token from `engine.py`'s parsed dict into `should_fire(loader_name, hook_data)`. Keep the `session_id`-keyed /tmp filename as the partition key (already correct). The `_turn` module cache may remain as an intra-process micro-opt but must NOT be the dedup authority.
|
||||
- **Correct cadence_investigation.md** outdated claims (user_prompt, no-session_id, single-process).
|
||||
|
||||
## FIX 2 — PRAX-VISIBLE FIRE/SKIP LOGGING (Patrick wants to SEE it in the monitor)
|
||||
Cadence already imports prax `system_logger`, and `system_logs/hooks_cadence.log` is ALREADY tailed live by `drone @prax monitor run` as `[HOOKS]`. The gap: `should_fire` logs nothing on the decision. Emit ONE structured INFO line at the `should_fire` choke point (covers all loaders, one site):
|
||||
|
||||
```
|
||||
[HOOKS] cadence <fired|skipped> loader=<name> action=<fired|skipped> turn=<N> period=<P> offset=<O> session=<8char>
|
||||
```
|
||||
|
||||
Use `.info` (SystemLogger has no `.debug`). ALSO gate/dedup the "counter reset" log — it spammed ~8x per cluster; confirm PreCompact reset fires EXACTLY once and logs once.
|
||||
|
||||
## FIX 3 — ACTION-GATED SOUND (the false signal Patrick HEARD)
|
||||
Right now `speak("global prompt")` / `speak("branch prompt")` is the FIRST line of each loader, BEFORE the `should_fire` check — so piper announces every turn even when the loader SKIPS injection. The voice lies. Patrick's rule: **if global/branch SKIP, they must be SILENT — sound ONLY on actual injection.**
|
||||
|
||||
Build the **system-wide** version (Patrick wants it right for ALL hooks): handlers return an explicit `sound` key in their result dict, e.g. `{"stdout": content, "sound": "global prompt", "exit_code": 0}`; the engine plays it at `engine.py:208` inside the `if result["stdout"]:` block (or whenever the `sound` key is present) — ONE integration point, every hook auto action-gated + self-identifying. Remove the scattered leading `speak()` calls from the loaders. Preserve the gates/notifications that legitimately emit empty stdout (let them set the `sound` key explicitly). `is_muted()` still short-circuits.
|
||||
|
||||
Sound architecture for reference: `hooks/apps/sound.py` `speak()`/`play()` → piper → aplay; mute flag `/tmp/aipass-hooks-muted`.
|
||||
|
||||
## TEST PLAN (this is what 435 green MISSED — required)
|
||||
- **Model separate-process execution:** simulate N independent processes each calling `_load_and_increment` for the same turn (no shared module cache) and assert the counter advances EXACTLY ONCE. REWRITE `test_cadence.py:113` `test_counter_increments_once_per_process` (it encodes the invalid single-process assumption).
|
||||
- Assert the leapfrog is gone: two loaders in the same turn see the SAME turn number — both fire on offset-0 turns, both skip otherwise.
|
||||
- Assert reset → next turn = 0 = all fire (the turn<0 special-case survives the debounce).
|
||||
- Assert SKIP = silent (no `sound` key) AND logs `action=skipped` (not fired).
|
||||
- Assert flock prevents double-increment under simulated simultaneity.
|
||||
|
||||
## ACCEPTANCE
|
||||
Multi-process simulation tests green + seedgo 100% + pyright 0. But do **NOT** claim "works" from unit tests alone — that is exactly what failed. devpulse will LIVE-VERIFY next session (prax monitor shows correct fire/skip, sound only on inject, counter advances once/turn). Report what you built + test results. NO git commits (devpulse commits). Reply via dispatch if blocked.
|
||||
|
||||
Track in your FPLAN (reopen FPLAN-0249). This is the careful re-do — get it right, verify against the REAL execution model.
|
||||
@@ -7,7 +7,12 @@
|
||||
# Modified: 2026-05-22
|
||||
# =============================================
|
||||
|
||||
"""Tests for handlers/lifecycle/auto_fix.py."""
|
||||
"""Tests for handlers/lifecycle/auto_fix.py.
|
||||
|
||||
NOTE: sound is action-gated via the result "sound" key — it is set to
|
||||
"auto fix diagnostics" only on the error-surfacing path; clean and skip
|
||||
paths stay silent (no "sound" key).
|
||||
"""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
@@ -61,32 +66,31 @@ class TestAutoFixSkips:
|
||||
def test_skip_unknown_extension(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak"):
|
||||
result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/file.xyz"}})
|
||||
result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/file.xyz"}})
|
||||
assert result["stdout"] == ""
|
||||
assert result["exit_code"] == 0
|
||||
assert "sound" not in result
|
||||
|
||||
|
||||
class TestAutofixPython:
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak")
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[])
|
||||
def test_python_no_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo, mock_speak):
|
||||
def test_python_no_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/clean.py"}})
|
||||
assert result["exit_code"] == 0
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert parsed["systemMessage"] == "[diagnostics] ok"
|
||||
assert "sound" not in result
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak")
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks")
|
||||
def test_python_syntax_error(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo, mock_speak):
|
||||
def test_python_syntax_error(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
mock_py.return_value = ["SYNTAX: invalid syntax at line 5"]
|
||||
@@ -96,25 +100,25 @@ class TestAutofixPython:
|
||||
assert "additionalContext" in parsed.get("hookSpecificOutput", {})
|
||||
assert "SYNTAX" in parsed["hookSpecificOutput"]["additionalContext"]
|
||||
assert "1 error(s)" in parsed["systemMessage"]
|
||||
assert result.get("sound") == "auto fix diagnostics"
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak")
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks")
|
||||
def test_python_ruff_lint_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo, mock_speak):
|
||||
def test_python_ruff_lint_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
mock_py.return_value = ["LINT: bad.py:10:1: F401 unused import"]
|
||||
result = handle({"tool_name": "Write", "tool_input": {"file_path": "/tmp/bad.py"}})
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert "LINT" in parsed["hookSpecificOutput"]["additionalContext"]
|
||||
assert result.get("sound") == "auto fix diagnostics"
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak")
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[])
|
||||
def test_python_pyright_errors(self, mock_py, mock_ruff_s, mock_seedgo, mock_speak):
|
||||
def test_python_pyright_errors(self, mock_py, mock_ruff_s, mock_seedgo):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
with patch(
|
||||
@@ -125,12 +129,12 @@ class TestAutofixPython:
|
||||
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert "TYPE: L42" in parsed["hookSpecificOutput"]["additionalContext"]
|
||||
assert result.get("sound") == "auto fix diagnostics"
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak")
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[])
|
||||
def test_seedgo_violations_surfaced(self, mock_py, mock_ruff_s, mock_pyright, mock_speak):
|
||||
def test_seedgo_violations_surfaced(self, mock_py, mock_ruff_s, mock_pyright):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
with patch(
|
||||
@@ -141,15 +145,15 @@ class TestAutofixPython:
|
||||
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert "SEEDGO: missing file header" in parsed["hookSpecificOutput"]["additionalContext"]
|
||||
assert result.get("sound") == "auto fix diagnostics"
|
||||
|
||||
|
||||
class TestAutoFixStateFile:
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak")
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check")
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured")
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[])
|
||||
def test_state_file_written_on_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo, mock_speak):
|
||||
def test_state_file_written_on_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
mock_ruff_s.return_value = [{"line": 5, "message": "F401: unused import"}]
|
||||
@@ -160,8 +164,9 @@ class TestAutoFixStateFile:
|
||||
|
||||
try:
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.STATE_FILE", state_path):
|
||||
handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/errors.py"}})
|
||||
result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/errors.py"}})
|
||||
|
||||
assert result.get("sound") == "auto fix diagnostics"
|
||||
assert state_path.exists()
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
assert len(state["errors"]) == 2
|
||||
@@ -171,12 +176,11 @@ class TestAutoFixStateFile:
|
||||
if state_path.exists():
|
||||
state_path.unlink()
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak")
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[])
|
||||
def test_state_file_cleared_on_no_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo, mock_speak):
|
||||
def test_state_file_cleared_on_no_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".json", delete=False, mode="w") as tf:
|
||||
@@ -185,8 +189,9 @@ class TestAutoFixStateFile:
|
||||
|
||||
try:
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.STATE_FILE", state_path):
|
||||
handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/clean.py"}})
|
||||
result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/clean.py"}})
|
||||
|
||||
assert "sound" not in result
|
||||
assert not state_path.exists()
|
||||
finally:
|
||||
if state_path.exists():
|
||||
@@ -194,8 +199,7 @@ class TestAutoFixStateFile:
|
||||
|
||||
|
||||
class TestAutoFixJson:
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak")
|
||||
def test_json_valid(self, mock_speak, tmp_path):
|
||||
def test_json_valid(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
json_file = tmp_path / "good.json"
|
||||
@@ -204,9 +208,9 @@ class TestAutoFixJson:
|
||||
result = handle({"tool_name": "Edit", "tool_input": {"file_path": str(json_file)}})
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert parsed["systemMessage"] == "[diagnostics] ok"
|
||||
assert "sound" not in result
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak")
|
||||
def test_json_invalid_syntax(self, mock_speak, tmp_path):
|
||||
def test_json_invalid_syntax(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
json_file = tmp_path / "bad.json"
|
||||
@@ -215,9 +219,9 @@ class TestAutoFixJson:
|
||||
result = handle({"tool_name": "Write", "tool_input": {"file_path": str(json_file)}})
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert "JSON SYNTAX" in parsed["hookSpecificOutput"]["additionalContext"]
|
||||
assert result.get("sound") == "auto fix diagnostics"
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak")
|
||||
def test_json_corruption_detected(self, mock_speak, tmp_path):
|
||||
def test_json_corruption_detected(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle
|
||||
|
||||
json_file = tmp_path / "corrupt.json"
|
||||
@@ -226,6 +230,7 @@ class TestAutoFixJson:
|
||||
result = handle({"tool_name": "Edit", "tool_input": {"file_path": str(json_file)}})
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert "EMOJI CORRUPTION" in parsed["hookSpecificOutput"]["additionalContext"]
|
||||
assert result.get("sound") == "auto fix diagnostics"
|
||||
|
||||
|
||||
class TestAutoFixSubprocessChecks:
|
||||
|
||||
@@ -31,13 +31,13 @@ class TestBranchLoaderHandler:
|
||||
prompt = aipass_dir / "aipass_local_prompt.md"
|
||||
prompt.write_text("# Test Branch\nSome instructions", encoding="utf-8")
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "Branch Context:" in result["stdout"]
|
||||
assert "Some instructions" in result["stdout"]
|
||||
assert result["sound"] == "branch prompt"
|
||||
|
||||
def test_loads_private_integrations(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.branch_loader import handle
|
||||
@@ -49,11 +49,11 @@ class TestBranchLoaderHandler:
|
||||
private = integration / "private_prompt.md"
|
||||
private.write_text("# Private Integration\nSecret stuff", encoding="utf-8")
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert "Private Integration" in result["stdout"]
|
||||
assert result["sound"] == "branch prompt"
|
||||
|
||||
def test_loads_both_prompt_and_integrations(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.branch_loader import handle
|
||||
@@ -67,9 +67,8 @@ class TestBranchLoaderHandler:
|
||||
integration.mkdir(parents=True)
|
||||
(integration / "private_prompt.md").write_text("Compass prompt", encoding="utf-8")
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert "Branch prompt" in result["stdout"]
|
||||
assert "Compass prompt" in result["stdout"]
|
||||
@@ -77,11 +76,11 @@ class TestBranchLoaderHandler:
|
||||
def test_returns_empty_when_no_branch_root(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.branch_loader import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
def test_stops_at_repo_root(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.branch_loader import handle
|
||||
@@ -90,11 +89,11 @@ class TestBranchLoaderHandler:
|
||||
nested = tmp_path / "some" / "deep" / "path"
|
||||
nested.mkdir(parents=True)
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({"cwd": str(nested)})
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({"cwd": str(nested)})
|
||||
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
def test_walks_up_to_find_branch(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.branch_loader import handle
|
||||
@@ -107,22 +106,23 @@ class TestBranchLoaderHandler:
|
||||
nested = tmp_path / "apps" / "handlers" / "security"
|
||||
nested.mkdir(parents=True)
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({"cwd": str(nested)})
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({"cwd": str(nested)})
|
||||
|
||||
assert "Found it" in result["stdout"]
|
||||
|
||||
def test_empty_hook_data(self):
|
||||
from aipass.hooks.apps.handlers.prompt.branch_loader import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"):
|
||||
# Path.cwd patch must be OUTSIDE the importlib patch — mock.patch uses
|
||||
# importlib.import_module to resolve "pathlib", which the inner mock hijacks.
|
||||
with patch("pathlib.Path.cwd", return_value=Path("/tmp/nonexistent")):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
with patch("pathlib.Path.cwd", return_value=Path("/tmp/nonexistent")):
|
||||
result = handle({})
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
def test_no_prompt_file_but_has_branch_root(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.branch_loader import handle
|
||||
@@ -130,11 +130,11 @@ class TestBranchLoaderHandler:
|
||||
trinity = tmp_path / ".trinity"
|
||||
trinity.mkdir()
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
def test_includes_source_path_in_output(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.branch_loader import handle
|
||||
@@ -145,8 +145,7 @@ class TestBranchLoaderHandler:
|
||||
aipass_dir.mkdir()
|
||||
(aipass_dir / "aipass_local_prompt.md").write_text("content", encoding="utf-8")
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert "Source:" in result["stdout"]
|
||||
|
||||
@@ -7,23 +7,42 @@
|
||||
# Modified: 2026-06-08
|
||||
# =============================================
|
||||
|
||||
"""Tests for apps/modules/cadence.py."""
|
||||
"""Tests for apps/modules/cadence.py.
|
||||
|
||||
Cadence runs MULTI-PROCESS in production: each UserPromptSubmit hook is a
|
||||
separate OS process. Tests model that by resetting the module _turn cache
|
||||
between calls (= new process) and aging the state file past the mtime
|
||||
debounce window (= a real prior turn, not a sibling in the same turn).
|
||||
"""
|
||||
|
||||
import json
|
||||
import importlib
|
||||
import os
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
MODULE = "aipass.hooks.apps.modules.cadence"
|
||||
|
||||
|
||||
def _reset_module_globals():
|
||||
"""Reset module-level caches between tests."""
|
||||
"""Reset module-level caches between tests (also = simulate a new process)."""
|
||||
import aipass.hooks.apps.modules.cadence as mod
|
||||
|
||||
mod._turn = None
|
||||
mod._config = None
|
||||
|
||||
|
||||
def _write_state(tmp_path, turn, token=-1, session="test-session", aged=True):
|
||||
"""Write a cadence state file. aged=True backdates mtime past the debounce
|
||||
window so it reads as a PREVIOUS turn; aged=False = sibling in same turn."""
|
||||
state_file = tmp_path / f"aipass-cadence-{session}.json"
|
||||
state_file.write_text(json.dumps({"turn": turn, "token": token}))
|
||||
if aged:
|
||||
old = time.time() - 10
|
||||
os.utime(state_file, (old, old))
|
||||
return state_file
|
||||
|
||||
|
||||
class TestShouldFire:
|
||||
def setup_method(self):
|
||||
_reset_module_globals()
|
||||
@@ -55,8 +74,7 @@ class TestShouldFire:
|
||||
def test_non_fire_turn_returns_false(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.cadence import should_fire
|
||||
|
||||
state_file = tmp_path / "aipass-cadence-test-session.json"
|
||||
state_file.write_text(json.dumps({"turn": 0}))
|
||||
_write_state(tmp_path, turn=0)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
@@ -68,8 +86,7 @@ class TestShouldFire:
|
||||
def test_fire_turn_returns_true(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.cadence import should_fire
|
||||
|
||||
state_file = tmp_path / "aipass-cadence-test-session.json"
|
||||
state_file.write_text(json.dumps({"turn": 3}))
|
||||
_write_state(tmp_path, turn=3)
|
||||
|
||||
config = tmp_path / "cadence.json"
|
||||
config.write_text(json.dumps({"enabled": True, "period": 5, "loaders": {"global": {"offset": 4}}}))
|
||||
@@ -110,11 +127,12 @@ class TestShouldFire:
|
||||
with patch.dict("os.environ", env, clear=True):
|
||||
assert should_fire("global") is True
|
||||
|
||||
def test_counter_increments_once_per_process(self, tmp_path):
|
||||
def test_counter_increments_once_across_sibling_processes(self, tmp_path):
|
||||
"""Each loader is a SEPARATE OS process. The counter must advance
|
||||
exactly once per real turn no matter how many siblings call it."""
|
||||
from aipass.hooks.apps.modules.cadence import should_fire
|
||||
|
||||
state_file = tmp_path / "aipass-cadence-test-session.json"
|
||||
state_file.write_text(json.dumps({"turn": 3}))
|
||||
state_file = _write_state(tmp_path, turn=3)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
@@ -122,10 +140,61 @@ class TestShouldFire:
|
||||
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
||||
):
|
||||
should_fire("global")
|
||||
should_fire("branch")
|
||||
for _ in range(4): # 4 more siblings, each a fresh process
|
||||
_reset_module_globals()
|
||||
should_fire("branch")
|
||||
data = json.loads(state_file.read_text())
|
||||
assert data["turn"] == 4
|
||||
|
||||
def test_sibling_processes_agree_on_turn_no_leapfrog(self, tmp_path):
|
||||
"""The S210 live bug: global saw turn N, branch saw N+1 — they
|
||||
leapfrogged and never both fired. Both siblings must see the SAME
|
||||
turn and make the SAME decision."""
|
||||
from aipass.hooks.apps.modules.cadence import should_fire
|
||||
|
||||
_write_state(tmp_path, turn=4) # next real turn = 5 = fire (5 % 5 == 0)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
||||
):
|
||||
assert should_fire("global") is True
|
||||
_reset_module_globals() # branch runs as a separate process
|
||||
assert should_fire("branch") is True
|
||||
|
||||
def test_token_backstop_blocks_double_increment(self, tmp_path):
|
||||
"""Even past the debounce window, an unchanged transcript token means
|
||||
no new turn happened — the counter must not advance."""
|
||||
from aipass.hooks.apps.modules.cadence import should_fire
|
||||
|
||||
transcript = tmp_path / "transcript.jsonl"
|
||||
transcript.write_text("x" * 100)
|
||||
state_file = _write_state(tmp_path, turn=3, token=100)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
||||
):
|
||||
should_fire("global", {"transcript_path": str(transcript)})
|
||||
assert json.loads(state_file.read_text())["turn"] == 3
|
||||
|
||||
def test_reset_special_case_survives_debounce(self, tmp_path):
|
||||
"""turn < 0 (post-compact reset) must ALWAYS increment to 0, even when
|
||||
the reset just happened (fresh mtime would normally debounce)."""
|
||||
from aipass.hooks.apps.modules.cadence import should_fire
|
||||
|
||||
state_file = _write_state(tmp_path, turn=-1, aged=False)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
||||
):
|
||||
assert should_fire("global") is True
|
||||
assert json.loads(state_file.read_text())["turn"] == 0
|
||||
|
||||
def test_period_zero_always_fires(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.cadence import should_fire
|
||||
|
||||
@@ -148,8 +217,7 @@ class TestShouldFire:
|
||||
json.dumps({"enabled": True, "period": 5, "loaders": {"global": {"offset": 0}, "branch": {"offset": 2}}})
|
||||
)
|
||||
|
||||
state_file = tmp_path / "aipass-cadence-test-session.json"
|
||||
state_file.write_text(json.dumps({"turn": 4}))
|
||||
_write_state(tmp_path, turn=4)
|
||||
|
||||
from aipass.hooks.apps.modules.cadence import should_fire
|
||||
|
||||
@@ -164,8 +232,7 @@ class TestShouldFire:
|
||||
def test_unknown_loader_uses_offset_zero(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.cadence import should_fire
|
||||
|
||||
state_file = tmp_path / "aipass-cadence-test-session.json"
|
||||
state_file.write_text(json.dumps({"turn": 4}))
|
||||
_write_state(tmp_path, turn=4)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
@@ -312,8 +379,7 @@ class TestPerSessionIsolation:
|
||||
def test_different_sessions_use_different_files(self, tmp_path):
|
||||
from aipass.hooks.apps.modules.cadence import should_fire
|
||||
|
||||
state_a = tmp_path / "aipass-cadence-session-a.json"
|
||||
state_a.write_text(json.dumps({"turn": 4}))
|
||||
state_a = _write_state(tmp_path, turn=4, session="session-a")
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
@@ -391,35 +457,35 @@ class TestLoaderCadenceGuard:
|
||||
_reset_module_globals()
|
||||
|
||||
def test_global_loader_skips_on_non_fire_turn(self, tmp_path):
|
||||
"""Skip = empty stdout AND no sound key — a skipped loader is SILENT."""
|
||||
from aipass.hooks.apps.handlers.prompt.global_loader import handle
|
||||
|
||||
state_file = tmp_path / "aipass-cadence-test-session.json"
|
||||
state_file.write_text(json.dumps({"turn": 0}))
|
||||
_write_state(tmp_path, turn=0) # next turn = 1 = skip
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
||||
patch("aipass.hooks.apps.handlers.prompt.global_loader.speak"),
|
||||
):
|
||||
result = handle({})
|
||||
|
||||
assert result["stdout"] == ""
|
||||
assert result["exit_code"] == 0
|
||||
assert "sound" not in result
|
||||
|
||||
def test_branch_loader_skips_on_non_fire_turn(self, tmp_path):
|
||||
"""Skip = empty stdout AND no sound key — a skipped loader is SILENT."""
|
||||
from aipass.hooks.apps.handlers.prompt.branch_loader import handle
|
||||
|
||||
state_file = tmp_path / "aipass-cadence-test-session.json"
|
||||
state_file.write_text(json.dumps({"turn": 0}))
|
||||
_write_state(tmp_path, turn=0) # next turn = 1 = skip
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}._GUARD_DIR", tmp_path),
|
||||
patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}),
|
||||
patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"),
|
||||
patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"),
|
||||
):
|
||||
result = handle({})
|
||||
|
||||
assert result["stdout"] == ""
|
||||
assert result["exit_code"] == 0
|
||||
assert "sound" not in result
|
||||
|
||||
@@ -29,25 +29,25 @@ class TestCompactHandler:
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact.speak"):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value="Git branch: dev"):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value="Git branch: dev"):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "POST-COMPACT RECOVERY" in result["stdout"]
|
||||
assert "Git branch: dev" in result["stdout"]
|
||||
assert "did stuff" in result["stdout"]
|
||||
assert "STATUS.local.md" not in result["stdout"]
|
||||
assert result["sound"] == "pre compact"
|
||||
|
||||
def test_returns_recovery_when_no_branch_dir(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.compact import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact.speak"):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None):
|
||||
result = handle({"cwd": "/tmp/nonexistent"})
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None):
|
||||
result = handle({"cwd": "/tmp/nonexistent"})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "POST-COMPACT RECOVERY" in result["stdout"]
|
||||
assert result["sound"] == "pre compact"
|
||||
|
||||
def test_dispatched_agent_gets_save_warning(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.lifecycle.compact import handle
|
||||
@@ -55,10 +55,9 @@ class TestCompactHandler:
|
||||
trinity = tmp_path / ".trinity"
|
||||
trinity.mkdir()
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact.speak"):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None):
|
||||
with patch.dict("os.environ", {"AIPASS_SESSION_TYPE": "dispatched"}):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None):
|
||||
with patch.dict("os.environ", {"AIPASS_SESSION_TYPE": "dispatched"}):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert "SAVE STATE NOW" in result["stdout"]
|
||||
assert "STATUS.local.md" not in result["stdout"]
|
||||
@@ -69,10 +68,9 @@ class TestCompactHandler:
|
||||
trinity = tmp_path / ".trinity"
|
||||
trinity.mkdir()
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact.speak"):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None):
|
||||
with patch.dict("os.environ", {"AIPASS_SESSION_TYPE": ""}):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None):
|
||||
with patch.dict("os.environ", {"AIPASS_SESSION_TYPE": ""}):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert "Recovery Protocol" in result["stdout"]
|
||||
assert "STATUS.local.md" not in result["stdout"]
|
||||
@@ -80,9 +78,8 @@ class TestCompactHandler:
|
||||
def test_empty_hook_data(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.compact import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact.speak"):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None):
|
||||
with patch("pathlib.Path.cwd", return_value=MagicMock(parts=("/", "tmp"))):
|
||||
result = handle({})
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None):
|
||||
with patch("pathlib.Path.cwd", return_value=MagicMock(parts=("/", "tmp"))):
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
@@ -29,26 +29,26 @@ class TestGlobalLoaderHandler:
|
||||
prompt.write_text("# AIPass Global\nContext here", encoding="utf-8")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.global_loader.speak"):
|
||||
with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({})
|
||||
with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "AIPass Global" in result["stdout"]
|
||||
assert "Context here" in result["stdout"]
|
||||
assert result["sound"] == "global prompt"
|
||||
|
||||
def test_returns_empty_when_file_missing(self, tmp_path, monkeypatch):
|
||||
from aipass.hooks.apps.handlers.prompt.global_loader import handle
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
with patch("aipass.hooks.apps.handlers.prompt.global_loader.speak"):
|
||||
with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({})
|
||||
with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
def test_empty_hook_data(self, tmp_path, monkeypatch):
|
||||
from aipass.hooks.apps.handlers.prompt.global_loader import handle
|
||||
@@ -58,10 +58,9 @@ class TestGlobalLoaderHandler:
|
||||
(aipass_dir / "aipass_global_prompt.md").write_text("content", encoding="utf-8")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.global_loader.speak"):
|
||||
with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({})
|
||||
with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == "content"
|
||||
@@ -77,10 +76,10 @@ class TestGlobalLoaderHandler:
|
||||
(aipass_dir / "aipass_global_prompt.md").write_text("# Project Prompt", encoding="utf-8")
|
||||
monkeypatch.chdir(project)
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.global_loader.speak"):
|
||||
with patch.dict("os.environ", {"AIPASS_HOME": "/some/other/path"}):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({})
|
||||
with patch.dict("os.environ", {"AIPASS_HOME": "/some/other/path"}):
|
||||
with patch("importlib.import_module", return_value=_mock_cadence_fires()):
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "Project Prompt" in result["stdout"]
|
||||
assert result["sound"] == "global prompt"
|
||||
|
||||
@@ -40,22 +40,22 @@ class TestIdentityHandler:
|
||||
passport = trinity / "passport.json"
|
||||
passport.write_text(json.dumps(SAMPLE_PASSPORT), encoding="utf-8")
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.identity.speak"):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "devpulse Identity" in result["stdout"]
|
||||
assert "orchestration_hub" in result["stdout"]
|
||||
assert "Pragmatic" in result["stdout"]
|
||||
assert result["sound"] == "identity"
|
||||
|
||||
def test_returns_empty_when_no_passport(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.identity import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.identity.speak"):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
def test_walks_up_to_find_passport(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.identity import handle
|
||||
@@ -67,8 +67,7 @@ class TestIdentityHandler:
|
||||
nested = tmp_path / "apps" / "handlers"
|
||||
nested.mkdir(parents=True)
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.identity.speak"):
|
||||
result = handle({"cwd": str(nested)})
|
||||
result = handle({"cwd": str(nested)})
|
||||
|
||||
assert "devpulse Identity" in result["stdout"]
|
||||
|
||||
@@ -80,8 +79,7 @@ class TestIdentityHandler:
|
||||
passport = trinity / "passport.json"
|
||||
passport.write_text(json.dumps(SAMPLE_PASSPORT), encoding="utf-8")
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.identity.speak"):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
out = result["stdout"]
|
||||
assert "Path: src/aipass/devpulse" in out
|
||||
@@ -100,21 +98,21 @@ class TestIdentityHandler:
|
||||
passport = trinity / "passport.json"
|
||||
passport.write_text(json.dumps({"branch_info": {"branch_name": "test"}, "identity": {}}), encoding="utf-8")
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.identity.speak"):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "test Identity" in result["stdout"]
|
||||
assert result["sound"] == "identity"
|
||||
|
||||
def test_empty_hook_data(self):
|
||||
from aipass.hooks.apps.handlers.prompt.identity import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.identity.speak"):
|
||||
with patch("pathlib.Path.cwd", return_value=Path("/tmp/nonexistent")):
|
||||
result = handle({})
|
||||
with patch("pathlib.Path.cwd", return_value=Path("/tmp/nonexistent")):
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
def test_corrupt_passport_json(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt.identity import handle
|
||||
@@ -124,8 +122,8 @@ class TestIdentityHandler:
|
||||
passport = trinity / "passport.json"
|
||||
passport.write_text("{broken json", encoding="utf-8")
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.prompt.identity.speak"):
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
result = handle({"cwd": str(tmp_path)})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
@@ -17,39 +17,37 @@ class TestRolloverHandler:
|
||||
def test_no_repo_root_returns_empty(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.rollover import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover.speak"):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_repo_root", return_value=None):
|
||||
result = handle({})
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_repo_root", return_value=None):
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
def test_no_overdue_returns_empty(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.rollover import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover.speak"):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_repo_root", return_value=MagicMock()):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_overdue", return_value=[]):
|
||||
result = handle({})
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_repo_root", return_value=MagicMock()):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_overdue", return_value=[]):
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
def test_overdue_triggers_rollover(self):
|
||||
from aipass.hooks.apps.handlers.lifecycle.rollover import handle
|
||||
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover.speak"):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_repo_root", return_value=MagicMock()):
|
||||
with patch(
|
||||
"aipass.hooks.apps.handlers.lifecycle.rollover._find_overdue",
|
||||
return_value=[("devpulse", "local", "21/20 sessions")],
|
||||
):
|
||||
with patch(
|
||||
"aipass.hooks.apps.handlers.lifecycle.rollover._run_rollover", return_value=(True, "ok")
|
||||
):
|
||||
result = handle({})
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_repo_root", return_value=MagicMock()):
|
||||
with patch(
|
||||
"aipass.hooks.apps.handlers.lifecycle.rollover._find_overdue",
|
||||
return_value=[("devpulse", "local", "21/20 sessions")],
|
||||
):
|
||||
with patch("aipass.hooks.apps.handlers.lifecycle.rollover._run_rollover", return_value=(True, "ok")):
|
||||
result = handle({})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["sound"] == "pre compact rollover"
|
||||
|
||||
def test_check_file_v2_sessions_overdue(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.lifecycle.rollover import _check_file
|
||||
|
||||
@@ -18,24 +18,23 @@ from aipass.hooks.apps.handlers.security.subagent_gate import handle
|
||||
class TestSubagentGateHandler:
|
||||
def test_no_repo_root_allows(self):
|
||||
with patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root", return_value=None):
|
||||
with patch("aipass.hooks.apps.handlers.security.subagent_gate.speak"):
|
||||
result = handle({"cwd": "/tmp/nowhere"})
|
||||
result = handle({"cwd": "/tmp/nowhere"})
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
def test_no_modified_files_allows(self):
|
||||
with patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root", return_value=None):
|
||||
with patch("aipass.hooks.apps.handlers.security.subagent_gate.speak"):
|
||||
result = handle({"cwd": "/tmp/somewhere"})
|
||||
result = handle({"cwd": "/tmp/somewhere"})
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._check_hook_readme_accountability", return_value=None)
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._run_seedgo_checklist", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate.speak")
|
||||
def test_modified_files_no_violations_allows(self, mock_speak, mock_root, mock_modified, mock_seedgo, mock_readme):
|
||||
def test_modified_files_no_violations_allows(self, mock_root, mock_modified, mock_seedgo, mock_readme):
|
||||
from pathlib import Path
|
||||
|
||||
mock_root.return_value = Path("/fake/repo")
|
||||
@@ -43,13 +42,13 @@ class TestSubagentGateHandler:
|
||||
result = handle({"cwd": "/fake/repo/src/aipass/hooks"})
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._check_hook_readme_accountability", return_value=None)
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._run_seedgo_checklist")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate.speak")
|
||||
def test_violations_blocks(self, mock_speak, mock_root, mock_modified, mock_seedgo, mock_readme):
|
||||
def test_violations_blocks(self, mock_root, mock_modified, mock_seedgo, mock_readme):
|
||||
from pathlib import Path
|
||||
|
||||
mock_root.return_value = Path("/fake/repo")
|
||||
@@ -62,10 +61,10 @@ class TestSubagentGateHandler:
|
||||
assert "Missing docstring" in parsed["reason"]
|
||||
assert "No tests" in parsed["reason"]
|
||||
assert "bad.py" in parsed["reason"]
|
||||
assert result["sound"] == "subagent gate"
|
||||
|
||||
@patch("subprocess.run")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate.speak")
|
||||
def test_skip_claude_hooks_from_modified_files(self, mock_speak, mock_run, tmp_path):
|
||||
def test_skip_claude_hooks_from_modified_files(self, mock_run, tmp_path):
|
||||
|
||||
src = tmp_path / "src" / "aipass" / "hooks"
|
||||
src.mkdir(parents=True)
|
||||
@@ -91,8 +90,7 @@ class TestSubagentGateHandler:
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._run_seedgo_checklist", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate.speak")
|
||||
def test_readme_accountability_advisory(self, mock_speak, mock_root, mock_modified, mock_seedgo, mock_readme):
|
||||
def test_readme_accountability_advisory(self, mock_root, mock_modified, mock_seedgo, mock_readme):
|
||||
from pathlib import Path
|
||||
|
||||
mock_root.return_value = Path("/fake/repo")
|
||||
@@ -106,18 +104,18 @@ class TestSubagentGateHandler:
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert parsed["decision"] == "allow"
|
||||
assert "README" in parsed["reason"]
|
||||
assert "sound" not in result
|
||||
|
||||
def test_empty_hook_data_allows(self):
|
||||
with patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root", return_value=None):
|
||||
with patch("aipass.hooks.apps.handlers.security.subagent_gate.speak"):
|
||||
result = handle({})
|
||||
result = handle({})
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate.speak")
|
||||
def test_exception_in_get_modified_allows(self, mock_speak, mock_root, mock_modified):
|
||||
def test_exception_in_get_modified_allows(self, mock_root, mock_modified):
|
||||
from pathlib import Path
|
||||
|
||||
mock_root.return_value = Path("/fake/repo")
|
||||
@@ -125,6 +123,7 @@ class TestSubagentGateHandler:
|
||||
result = handle({"cwd": "/fake/repo/src/aipass/hooks"})
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
|
||||
class TestSubagentGateExternalProject:
|
||||
@@ -161,8 +160,7 @@ class TestSubagentGateExternalProject:
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._run_seedgo_checklist")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate.speak")
|
||||
def test_violations_block_external_project(self, mock_speak, mock_root, mock_modified, mock_seedgo, mock_readme):
|
||||
def test_violations_block_external_project(self, mock_root, mock_modified, mock_seedgo, mock_readme):
|
||||
from pathlib import Path
|
||||
|
||||
mock_root.return_value = Path("/fake/vera")
|
||||
@@ -173,13 +171,13 @@ class TestSubagentGateExternalProject:
|
||||
parsed = json.loads(result["stdout"])
|
||||
assert parsed["decision"] == "block"
|
||||
assert "Missing docstring" in parsed["reason"]
|
||||
assert result["sound"] == "subagent gate"
|
||||
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._check_hook_readme_accountability", return_value=None)
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._run_seedgo_checklist", return_value=[])
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root")
|
||||
@patch("aipass.hooks.apps.handlers.security.subagent_gate.speak")
|
||||
def test_clean_files_allow_external_project(self, mock_speak, mock_root, mock_modified, mock_seedgo, mock_readme):
|
||||
def test_clean_files_allow_external_project(self, mock_root, mock_modified, mock_seedgo, mock_readme):
|
||||
from pathlib import Path
|
||||
|
||||
mock_root.return_value = Path("/fake/vera")
|
||||
@@ -187,3 +185,4 @@ class TestSubagentGateExternalProject:
|
||||
result = handle({"cwd": "/fake/vera/src/vera_studio/quality"})
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == ""
|
||||
assert "sound" not in result
|
||||
|
||||
@@ -6,7 +6,7 @@ Handlers for file watching, log monitoring, branch detection, and filtering.
|
||||
"""
|
||||
|
||||
# Export main handler interfaces
|
||||
from .unified_stream import print_event, print_command_separator
|
||||
from .unified_stream import print_event, print_command_separator, print_hook_event
|
||||
from .branch_detector import detect_branch_from_path
|
||||
from .interactive_filter import (
|
||||
parse_command,
|
||||
@@ -34,6 +34,7 @@ from .log_watcher import start_log_watcher, stop_log_watcher, is_log_watcher_act
|
||||
__all__ = [
|
||||
"print_event",
|
||||
"print_command_separator",
|
||||
"print_hook_event",
|
||||
"detect_branch_from_path",
|
||||
"parse_command",
|
||||
"get_help_text",
|
||||
|
||||
@@ -102,9 +102,13 @@ class LogFileWatcher(FileSystemEventHandler):
|
||||
self.last_command_per_branch: Dict[str, str] = {}
|
||||
|
||||
def _process_log_line(self, branch: str, line: str, file_path: str) -> None:
|
||||
"""Process a single log line: detect commands or emit as log event."""
|
||||
"""Process a single log line: detect hooks, commands, or emit as log event."""
|
||||
if not line.strip():
|
||||
return
|
||||
hook_info = self._extract_hook_info(line)
|
||||
if hook_info:
|
||||
self._emit_hook_event(branch, hook_info)
|
||||
return
|
||||
command_info = self._extract_command_info(line)
|
||||
if command_info:
|
||||
self._emit_command_separator(branch, command_info)
|
||||
@@ -159,6 +163,52 @@ class LogFileWatcher(FileSystemEventHandler):
|
||||
except Exception as e:
|
||||
logger.info(f"Error reading log file {file_path}: {e}")
|
||||
|
||||
_HOOK_PATTERN = re.compile(r"\[HOOKS\]\s+(\w+)\s+.*?action=(\w+)")
|
||||
|
||||
def _extract_hook_info(self, log_line: str) -> Optional[Dict[str, str]]:
|
||||
"""Extract hook event info from structured [HOOKS] log lines.
|
||||
|
||||
Matches lines like:
|
||||
[HOOKS] cadence fired loader=global action=fired turn=35 period=5 ...
|
||||
[HOOKS] cadence skipped loader=branch action=skipped turn=37 period=5 ...
|
||||
"""
|
||||
match = self._HOOK_PATTERN.search(log_line)
|
||||
if not match:
|
||||
return None
|
||||
name = match.group(1)
|
||||
action = match.group(2)
|
||||
details: Dict[str, str] = {"name": name, "action": action}
|
||||
for kv_match in re.finditer(r"(\w+)=(\S+)", log_line):
|
||||
details[kv_match.group(1)] = kv_match.group(2)
|
||||
return details
|
||||
|
||||
def _emit_hook_event(self, branch: str, hook_info: Dict[str, str]) -> None:
|
||||
"""Emit a hook event to the monitoring queue."""
|
||||
action = hook_info.get("action", "unknown")
|
||||
name = hook_info.get("name", "hook")
|
||||
loader = hook_info.get("loader", "")
|
||||
turn = hook_info.get("turn", "")
|
||||
|
||||
parts = [f"{name}:{action}"]
|
||||
if loader:
|
||||
parts.append(f"loader={loader}")
|
||||
if turn:
|
||||
parts.append(f"turn={turn}")
|
||||
message = " ".join(parts)
|
||||
|
||||
level = "success" if action == "fired" else "info"
|
||||
|
||||
hook_event = MonitoringEvent(
|
||||
priority=2,
|
||||
event_type="hook",
|
||||
branch=branch,
|
||||
action=action,
|
||||
message=message,
|
||||
level=level,
|
||||
timestamp=datetime.now(),
|
||||
)
|
||||
self.event_queue.enqueue(hook_event)
|
||||
|
||||
def _should_display_log(self, _log_line: str) -> bool:
|
||||
"""Check if log line should be displayed. No filtering — show everything."""
|
||||
return True
|
||||
|
||||
@@ -185,6 +185,28 @@ def print_command_separator(branch: str, command: str, caller: Optional[str] = N
|
||||
console.print(f"[bold {branch_color}]{'─' * 60}[/bold {branch_color}]")
|
||||
|
||||
|
||||
def print_hook_event(branch: str, message: str, action: str = "unknown"):
|
||||
"""Print a hook event with distinct fired/skipped styling.
|
||||
|
||||
Args:
|
||||
branch: Branch the hook event originated from
|
||||
message: Hook event summary (e.g. "cadence:fired loader=global turn=35")
|
||||
action: "fired" or "skipped" (controls color)
|
||||
"""
|
||||
with _print_lock:
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
if action == "fired":
|
||||
style = "bold green"
|
||||
symbol = "⚡"
|
||||
elif action == "skipped":
|
||||
style = "dim"
|
||||
symbol = "·"
|
||||
else:
|
||||
style = "white"
|
||||
symbol = "?"
|
||||
console.print(f"[dim]{timestamp}[/dim] [{style}]{symbol} HOOK {message}[/{style}]")
|
||||
|
||||
|
||||
def print_status(watched_branches: List[str], verbosity: int, filters: Optional[Dict] = None):
|
||||
"""
|
||||
Display current monitoring status
|
||||
|
||||
@@ -38,6 +38,7 @@ from aipass.prax.apps.handlers.json import json_handler
|
||||
from aipass.prax.apps.handlers.monitoring import (
|
||||
print_event, # unified_stream.py
|
||||
print_command_separator, # unified_stream.py - command headers
|
||||
print_hook_event, # unified_stream.py - hook fire/skip display
|
||||
MonitoringQueue, # event_queue.py
|
||||
ModuleTracker, # module_tracker.py
|
||||
)
|
||||
@@ -338,6 +339,8 @@ def _render_event(event) -> None:
|
||||
if len(parts) == 2 and parts[1]:
|
||||
target = parts[1]
|
||||
print_command_separator(event.branch, event.message, caller, target)
|
||||
elif event.event_type == "hook":
|
||||
print_hook_event(event.branch, event.message, event.action)
|
||||
else:
|
||||
print_event(event.event_type, event.branch, event.message, event.level, pid=branch_pid)
|
||||
|
||||
|
||||
@@ -1063,3 +1063,105 @@ class TestInitializePositionsAdditional:
|
||||
watcher.initialize_positions()
|
||||
|
||||
assert str(log_file) not in watcher.log_positions
|
||||
|
||||
|
||||
class TestExtractHookInfo:
|
||||
"""Test _extract_hook_info for structured [HOOKS] log lines."""
|
||||
|
||||
def test_fired_line_extracted(self):
|
||||
"""Should extract name, action, and key-value details from a fired line."""
|
||||
mod = _import_log_watcher()
|
||||
watcher, _ = _make_watcher(mod)
|
||||
|
||||
line = "[HOOKS] cadence fired loader=global action=fired turn=35 period=5 offset=0 session=abc12345"
|
||||
result = watcher._extract_hook_info(line)
|
||||
assert result is not None
|
||||
assert result["name"] == "cadence"
|
||||
assert result["action"] == "fired"
|
||||
assert result["loader"] == "global"
|
||||
assert result["turn"] == "35"
|
||||
|
||||
def test_skipped_line_extracted(self):
|
||||
"""Should extract skipped hook events."""
|
||||
mod = _import_log_watcher()
|
||||
watcher, _ = _make_watcher(mod)
|
||||
|
||||
line = "[HOOKS] cadence skipped loader=branch action=skipped turn=37 period=5 offset=0 session=abc12345"
|
||||
result = watcher._extract_hook_info(line)
|
||||
assert result is not None
|
||||
assert result["name"] == "cadence"
|
||||
assert result["action"] == "skipped"
|
||||
assert result["loader"] == "branch"
|
||||
|
||||
def test_non_hook_line_returns_none(self):
|
||||
"""Non-hook log lines should return None."""
|
||||
mod = _import_log_watcher()
|
||||
watcher, _ = _make_watcher(mod)
|
||||
|
||||
result = watcher._extract_hook_info("[FLOW] Creating plan FPLAN-0099")
|
||||
assert result is None
|
||||
|
||||
def test_hook_line_without_action_returns_none(self):
|
||||
"""A [HOOKS] line without action= should return None."""
|
||||
mod = _import_log_watcher()
|
||||
watcher, _ = _make_watcher(mod)
|
||||
|
||||
result = watcher._extract_hook_info("[HOOKS] something happened no structured data")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestEmitHookEvent:
|
||||
"""Test _emit_hook_event queues properly."""
|
||||
|
||||
def test_fired_event_queued_with_correct_kwargs(self):
|
||||
"""Fired hook events should pass event_type=hook, level=success to MonitoringEvent."""
|
||||
mod = _import_log_watcher()
|
||||
watcher, mock_queue = _make_watcher(mod)
|
||||
|
||||
mock_event_cls = MagicMock()
|
||||
with patch.object(mod, "MonitoringEvent", mock_event_cls):
|
||||
hook_info = {"name": "cadence", "action": "fired", "loader": "global", "turn": "35"}
|
||||
watcher._emit_hook_event("HOOKS", hook_info)
|
||||
|
||||
mock_event_cls.assert_called_once()
|
||||
kwargs = mock_event_cls.call_args[1]
|
||||
assert kwargs["event_type"] == "hook"
|
||||
assert kwargs["action"] == "fired"
|
||||
assert kwargs["level"] == "success"
|
||||
assert "cadence:fired" in kwargs["message"]
|
||||
assert "loader=global" in kwargs["message"]
|
||||
assert "turn=35" in kwargs["message"]
|
||||
|
||||
def test_skipped_event_queued_with_info_level(self):
|
||||
"""Skipped hook events should pass level=info to MonitoringEvent."""
|
||||
mod = _import_log_watcher()
|
||||
watcher, mock_queue = _make_watcher(mod)
|
||||
|
||||
mock_event_cls = MagicMock()
|
||||
with patch.object(mod, "MonitoringEvent", mock_event_cls):
|
||||
hook_info = {"name": "cadence", "action": "skipped", "loader": "branch", "turn": "37"}
|
||||
watcher._emit_hook_event("HOOKS", hook_info)
|
||||
|
||||
kwargs = mock_event_cls.call_args[1]
|
||||
assert kwargs["action"] == "skipped"
|
||||
assert kwargs["level"] == "info"
|
||||
|
||||
def test_process_log_line_routes_hook_to_emit(self):
|
||||
"""Hook lines in _process_log_line should route to _emit_hook_event, not _emit_log_event."""
|
||||
mod = _import_log_watcher()
|
||||
watcher, mock_queue = _make_watcher(mod)
|
||||
|
||||
with (
|
||||
patch.object(watcher, "_emit_hook_event") as mock_hook,
|
||||
patch.object(watcher, "_emit_command_separator") as mock_cmd,
|
||||
patch.object(watcher, "_emit_log_event") as mock_log,
|
||||
):
|
||||
watcher._process_log_line(
|
||||
"HOOKS",
|
||||
"[HOOKS] cadence fired loader=global action=fired turn=35 period=5 offset=0 session=abc",
|
||||
"/fake/file.log",
|
||||
)
|
||||
|
||||
mock_hook.assert_called_once()
|
||||
mock_cmd.assert_not_called()
|
||||
mock_log.assert_not_called()
|
||||
|
||||
Reference in New Issue
Block a user