Compare commits

..
13 Commits
Author SHA1 Message Date
AIPass 4c33cc1f69 Merge pull request #619 from AIOSAI/dev
fix: deny EnterPlanMode in aipass init scaffold + Bluesky/dev.to drivers built
2026-05-29 22:56:58 -07:00
AIOSAI aa962bdc12 release: bump to 2.5.0 + finalize W22 CHANGELOG 2026-05-29 22:51:57 -07:00
AIOSAI fbd90d74b3 fix(hooks): Windows-safe subagent_gate tests via tmp_path 2026-05-29 22:43:52 -07:00
AIOSAI 5fe96307a4 ci: cut GitHub Release on tag from CHANGELOG
- publish.yml: new github-release job runs after PyPI publish, extracts the
  top CHANGELOG section as release notes, attaches dist, creates the Release
  via gh. Same v* tag now drives PyPI + GitHub Release.
- CHANGELOG W22 entry
2026-05-29 15:54:07 -07:00
AIOSAI e27a50c78d ci: add OpenSSF Scorecard workflow + README badge
- .github/workflows/scorecard.yml — official OSSF Scorecard action, runs on
  push to main + weekly, publishes public score, actions pinned by SHA
- README OpenSSF Scorecard badge
- CHANGELOG W22 entry
- CLAUDE.md startup-protocol guard (sequential steps, no batch/duplicate calls)
2026-05-29 15:41:14 -07:00
AIOSAIandClaude Opus 4.8 f8f102e16f fix(hooks): subagent_gate package-aware — closes #605
subagent_gate.py derived package name dynamically from CWD (mirrors edit_gate),
replacing 3 hardcoded src/aipass/ paths. Branch detection + cross-branch
protection now work for external projects (src/<package>/<branch>); previously
silently no-opped. 5 new external-project tests (258 pass), seedgo 100%.

Closes #605

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 00:33:50 -07:00
AIOSAIandClaude Opus 4.8 97a3276b81 hooks: full branch ownership — 100% seedgo + drone @hooks status
@hooks took full ownership of its branch (DPLAN-0191):
- 100% seedgo (all 36 standards; dead_code 25%->100%, unused_function 80%->100%)
- 15 handlers verified wired + firing; dynamic-dispatch flags documented as
  architectural bypasses (importlib dispatch from hooks.json, never statically imported)
- README rewritten: two-tier provider/project model, dynamic-dispatch design, event table
- 2 stale tests resolved (253 pass, 0 fail)
- New drone @hooks status read-only config viewer (DPLAN-0190 Phase B)

api/.gitignore: exclude integration tests (no code on user machines -> would fail)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 00:12:08 -07:00
AIOSAI 35a63b9540 fix: bootstrap @hooks as 13th branch + correct stale 12-counts
setup.sh never bootstrapped @hooks (hardcoded 12-branch list, stale comment from before hooks existed). Fresh clones got no @hooks passport + an absolute registry path -> drone @hooks commands failed ('path escapes project root'). Engine still fired (runs from AIPASS_HOME) but the citizen was non-functional.

- setup.sh: + bootstrap_branch hooks, 12->13 count + comment
- .aipass/aipass_global_prompt.md: 12->13 core branches, + hooks (injected every turn)
- devpulse local prompt: 13 Core Branches, + @hooks experts row
- dashboard dispatch_section docstring: 12->13

Found via Docker clone-from-dev test (DPLAN-0190 Phase D).
2026-05-28 19:51:27 -07:00
AIOSAI 574ae79b1a feat(hooks): ship .aipass/hooks.json on aipass init (DPLAN-0190 Phase A)
- bootstrap.py: write hooks.json from template at init, union-merge on update (preserves user on/off), remove dead _ship_hooks/HOOKS_TO_SHIP
- doctor.py: check .aipass/hooks.json presence
- .aipass/project_hooks.json: base template (all 14 handlers)
- .aipass/.gitignore: whitelist template so it ships in clones
- +13 tests, 421 pass, seedgo 99%
2026-05-28 19:41:10 -07:00
AIOSAI a752923ae2 fix: deny EnterPlanMode in aipass init scaffold 2026-05-27 13:48:34 -07:00
AIPass 0c5d26284c Merge pull request #617 from AIOSAI/dev
docs: CHANGELOG — logo + v2.4.0 release
2026-05-26 13:17:29 -07:00
AIOSAI b925714589 docs: CHANGELOG — logo + v2.4.0 release 2026-05-26 13:16:52 -07:00
AIPass 91a9eeb233 Merge pull request #616 from AIOSAI/bump-version-230-240
bump: version 2.3.0 → 2.4.0
2026-05-26 13:15:12 -07:00
31 changed files with 1203 additions and 624 deletions
+2 -1
View File
@@ -4,4 +4,5 @@
!.gitignore
!project_CLAUDE.md
!project_global_prompt.md
#Do not add other exceptions here without careful consideration. Developer permissions0ns needed.
!project_hooks.json
#Do not add other exceptions here without careful consideration. Developer permissions0ns needed.
+1 -1
View File
@@ -57,7 +57,7 @@ src/aipass/{name}/
└── README.md
```
12 core branches: aipass, drone, seedgo, prax, cli, flow, ai_mail, api, trigger, spawn, memory, devpulse.
13 core branches: aipass, drone, seedgo, prax, cli, flow, ai_mail, api, trigger, spawn, memory, devpulse, hooks.
# Commands
+99
View File
@@ -0,0 +1,99 @@
{
"_comment": "TEMPLATE: base per-project hook config copied into new projects by `aipass init` (DPLAN-0190). Mirrors AIPass's own .aipass/hooks.json. All handlers run from $AIPASS_HOME — projects only flip enabled true/false. Use `drone @hooks enable/disable <hook>` or edit here.",
"hooks_enabled": true,
"UserPromptSubmit": {
"identity_injector": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.prompt.identity.handle",
"matcher": ""
},
"email_notification": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.notification.email.handle",
"matcher": ""
},
"branch_prompt": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.prompt.branch_loader.handle",
"matcher": ""
},
"global_prompt": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.prompt.global_loader.handle",
"matcher": ""
}
},
"PreToolUse": {
"tool_use_sound": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.notification.tool_sound.handle",
"matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task"
},
"pre_edit_gate": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.security.edit_gate.handle",
"matcher": "Edit|MultiEdit|Write|NotebookEdit"
},
"git_gate": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.security.git_gate.handle",
"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"
}
},
"PostToolUse": {
"auto_fix_diagnostics": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.lifecycle.auto_fix.handle",
"matcher": "Edit|MultiEdit|Write|NotebookEdit",
"timeout": 45
},
"auto_watchdog": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.lifecycle.auto_watchdog.handle",
"matcher": "Bash"
}
},
"SubagentStop": {
"subagent_stop_gate": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.security.subagent_gate.handle",
"matcher": "",
"timeout": 60
}
},
"Stop": {
"stop_sound": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.notification.stop_sound.handle",
"matcher": ""
}
},
"Notification": {
"notification_sound": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.notification.announce.handle",
"matcher": ""
}
},
"PreCompact": {
"pre_compact": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.lifecycle.compact.handle",
"matcher": "",
"timeout": 60
},
"pre_compact_rollover": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.lifecycle.rollover.handle",
"matcher": "",
"timeout": 120
}
}
}
+25
View File
@@ -32,3 +32,28 @@ jobs:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
github-release:
needs: publish
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Extract latest CHANGELOG section
run: |
# Grab the topmost "## [...]" block from CHANGELOG.md as release notes.
awk '/^## \[/{c++} c==1' CHANGELOG.md | sed '/^---$/d' > release_notes.md
echo "Release notes:" && cat release_notes.md
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "${GITHUB_REF_NAME}" \
--title "${GITHUB_REF_NAME}" \
--notes-file release_notes.md \
dist/*
+46
View File
@@ -0,0 +1,46 @@
name: Scorecard analysis workflow
on:
push:
# Only the default branch is supported.
branches:
- main
schedule:
# Weekly on Saturdays.
- cron: '30 1 * * 6'
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
# Needed for Code scanning upload
security-events: write
# Needed for GitHub OIDC token if publish_results is true
id-token: write
steps:
- name: "Checkout code"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
with:
results_file: results.sarif
results_format: sarif
publish_results: true
- name: "Upload artifact"
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: SARIF file
path: results.sarif
retention-days: 5
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
with:
sarif_file: results.sarif
+38 -6
View File
@@ -8,24 +8,56 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format
---
## [2026.W22] - 2026-06-01
## [2026.W22] - 2026-05-30
### Added
- **`drone @hooks status`** — read-only viewer for a project's hook config:
master switch, every hook's enabled state per event group, matchers, and an
enabled/total summary. Resolves the project's `.aipass/hooks.json` by walking
up from CWD. (DPLAN-0190 Phase B)
- **Hooks activate in every project** — `aipass init` now writes
`.aipass/hooks.json`, so new projects fire the hook engine out of the box
(previously: no config shipped, 0 hooks fired). `aipass init update`
union-merges the template, preserving any per-hook on/off choices the user
made. `aipass doctor` now checks for the config's presence. Dead hook-script
shipping (`_ship_hooks`) removed. (DPLAN-0190 Phase A)
- **README logo** — centered logo image replaces plain `# AIPass` header.
New `assets/logo.png` added to the repo.
- **OpenSSF Scorecard** — `.github/workflows/scorecard.yml` runs the official
OSSF Scorecard action on push to `main` and weekly. Publishes a public security
health score at scorecard.dev with a README badge. Actions pinned by SHA.
- **GitHub Releases** — `publish.yml` now cuts a GitHub Release on each `v*` tag,
with notes pulled from the top CHANGELOG section and the built dist attached.
PyPI publish + GitHub Release now fire from the same tag.
- **Registry descriptions** — all 13 branches now have one-liner descriptions
in `AIPASS_REGISTRY.json`. `drone systems` shows what each agent does
instead of blank lines. Closes [#607](https://github.com/AIOSAI/AIPass/issues/607).
### Changed
- **Edit gate now project-aware** — cross-branch write protection and daemon
confinement no longer hardcode `src/aipass/`. The package name is derived
dynamically from CWD, so any `src/<package>/<branch>/` project gets the
same security. 4 new tests for external projects. Addresses
[#605](https://github.com/AIOSAI/AIPass/issues/605).
- **Security gates fully project-aware** — both the edit gate *and* the
subagent stop gate now derive the package name dynamically from CWD instead
of hardcoding `src/aipass/`. Cross-branch write protection and branch
detection work for any `src/<package>/<branch>/` project; previously the
subagent gate silently no-opped outside AIPass. 9 new external-project tests.
Closes [#605](https://github.com/AIOSAI/AIPass/issues/605).
- **Hooks branch promoted to service** — registry profile changed from
"AIPass Workshop" to "library" so it appears in `drone systems` alongside
the other 12 services.
- **Hooks branch hardened to 100% seedgo** — the @hooks citizen took full
ownership of its branch: every handler verified wired + firing, README
rewritten (two-tier provider/project model, dynamic-dispatch design, event
table), 2 stale tests resolved (253 pass). Dead-code/unused-function flags
documented as architectural bypasses — the 15 handlers are invoked
dynamically via `importlib` from `hooks.json` paths, never statically
imported. (DPLAN-0191)
### Release
- **Version 2.5.0** published to PyPI. Trusted publishing via GitHub Actions
(`publish.yml` triggers on `v*` tags — no manual twine upload needed). The
same tag now also cuts a GitHub Release with these notes attached.
### Removed
+2
View File
@@ -8,6 +8,8 @@ User: user
On any greeting, silently run this sequence — no narration, no announcing steps. Just do it and respond with the status.
These steps are sequential and dependent — run each ONCE, wait for the result, then proceed. Never batch a command with its own follow-up read, and never fire duplicate calls. If output looks blank, wait — don't retry.
- Read: `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md`, `STATUS.local.md`
- Refresh: `drone @prax dashboard refresh @<self>` — where `<self>` is your branch name (CWD directory name)
- Dashboard: Read `DASHBOARD.local.json` — act on what needs attention (new mail → check inbox, active plans → note them). This is your single status glance.
+3
View File
@@ -12,6 +12,9 @@
</p>
<p align="center"><strong>Persistent Agent Workspace</strong></p>
<p align="center"><em>AI agents that remember, collaborate, and never start from zero.</em></p>
<p align="center">
<a href="https://scorecard.dev/viewer/?uri=github.com/AIOSAI/AIPass"><img src="https://api.scorecard.dev/projects/github.com/AIOSAI/AIPass/badge" alt="OpenSSF Scorecard" /></a>
</p>
![demo](assets/demo.gif)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "aipass"
version = "2.4.0"
version = "2.5.0"
description = "A local multi-agent framework where your AI agents keep their memory, work together, and never ask you to re-explain context"
readme = "README.md"
license = "MIT"
+3 -2
View File
@@ -478,12 +478,13 @@ bootstrap_branch "spawn" "$SCRIPT_DIR/src/aipass/spawn" "builder" "Branch
bootstrap_branch "devpulse" "$SCRIPT_DIR/src/aipass/devpulse" "manager" "Orchestration hub and coordination"
bootstrap_branch "memory" "$SCRIPT_DIR/src/aipass/memory" "builder" "Vector memory bank"
bootstrap_branch "aipass" "$SCRIPT_DIR/src/aipass/aipass" "builder" "Concierge — init, doctor, profile, onboarding"
bootstrap_branch "hooks" "$SCRIPT_DIR/src/aipass/hooks" "builder" "Hook engine — cross-platform hook dispatch and per-project config"
# External branches
# NOTE: backup, daemon removed S82/S87. commons, skills moved to external repos.
# Only the 12 core branches above should be bootstrapped.
# Only the 13 core branches above should be bootstrapped.
echo " 12 branches bootstrapped"
echo " 13 branches bootstrapped"
# --- Seed branch config files from .example defaults ---
# Some branches need a config file that's gitignored (contains local state).
+1 -1
View File
@@ -4,4 +4,4 @@ pip install aipass
https://github.com/AIOSAI/AIPass
"""
__version__ = "2.2.0"
__version__ = "2.5.0"
+1 -1
View File
@@ -76,4 +76,4 @@ Humans only. Nothing in AIPass depends on this branch.
## Last Updated
Last Updated: 2026-05-16
Last Updated: 2026-05-28
@@ -43,60 +43,6 @@ from aipass.aipass.apps.handlers.init import scaffold_content as sc
logger = logging.getLogger(__name__)
# Hooks are NOT distributed to projects. All hooks fire from provider
# settings (~/.claude/settings.json), installed by setup.sh. Provider hooks
# use CWD-walking patterns that work from any directory in any project.
# Hook files are shipped as reference copies only (for debugging/inspection).
HOOKS_TO_SHIP = [
"branch_prompt_loader.py",
"email_notification.py",
"identity_injector.py",
"pre_compact.py",
"auto_fix_diagnostics.py",
"pre_edit_gate.py",
"subagent_stop_gate.py",
]
def _ship_hooks(aipass_home: str, target: Path) -> list[str]:
"""Copy enforcement + injector hooks from AIPass install to target project.
Copies each hook file to {target}/.claude/hooks/. Looks for hooks in two
locations (first match wins):
1. {aipass_home}/.claude/hooks/ — dev install (git clone)
2. aipass/_hooks/ — pip install (wheel-bundled)
Skips audio hooks. Overwrites existing files only if source content
differs (idempotent re-sync). Returns list of files written.
"""
source_dir = Path(aipass_home) / ".claude" / "hooks"
if not source_dir.is_dir():
# Fallback: pip install bundles hooks at aipass/_hooks/ inside the package
package_hooks = Path(__file__).resolve().parents[4] / "_hooks"
if package_hooks.is_dir():
source_dir = package_hooks
else:
logger.info("No hooks directory at %s or %s — skipping", source_dir, package_hooks)
return []
dest_dir = target / ".claude" / "hooks"
dest_dir.mkdir(parents=True, exist_ok=True)
shipped: list[str] = []
for hook_name in HOOKS_TO_SHIP:
src = source_dir / hook_name
dst = dest_dir / hook_name
if not src.exists():
logger.info("Hook %s not found at %s — skipping", hook_name, src)
continue
src_content = src.read_bytes()
if dst.exists() and dst.read_bytes() == src_content:
continue
shutil.copy2(src, dst)
shipped.append(str(dst))
return shipped
def _sanitize_name(raw: str) -> str:
"""Sanitize a project name for use in filenames.
@@ -201,6 +147,52 @@ def _merge_settings(existing: dict, generated: dict) -> dict:
return merged
def _merge_hooks_json(existing: dict, template: dict) -> dict:
"""Union-merge hooks.json: preserve user enabled values, add new hooks/events."""
merged: dict = {}
meta_keys = {"_comment", "hooks_enabled"}
if "_comment" in template:
merged["_comment"] = template["_comment"]
elif "_comment" in existing:
merged["_comment"] = existing["_comment"]
if "hooks_enabled" in existing:
merged["hooks_enabled"] = existing["hooks_enabled"]
elif "hooks_enabled" in template:
merged["hooks_enabled"] = template["hooks_enabled"]
all_events: set[str] = set()
for key in existing:
if key not in meta_keys:
all_events.add(key)
for key in template:
if key not in meta_keys:
all_events.add(key)
for event in sorted(all_events):
existing_hooks = existing.get(event, {})
template_hooks = template.get(event, {})
merged_hooks: dict = {}
for hook_name, hook_data in existing_hooks.items():
merged_hooks[hook_name] = dict(hook_data)
for hook_name, hook_data in template_hooks.items():
if hook_name in merged_hooks:
user_enabled = merged_hooks[hook_name].get("enabled")
merged_hooks[hook_name] = dict(hook_data)
if user_enabled is not None:
merged_hooks[hook_name]["enabled"] = user_enabled
else:
merged_hooks[hook_name] = dict(hook_data)
if merged_hooks:
merged[event] = merged_hooks
return merged
def _claude_settings(aipass_home: str | None = None) -> str:
"""Generate .claude/settings.json — env and permissions only.
@@ -224,6 +216,7 @@ def _claude_settings(aipass_home: str | None = None) -> str:
"Bash(rm -rf *)",
"Bash(git push --force*)",
"Bash(git reset --hard*)",
"EnterPlanMode",
],
}
@@ -337,6 +330,16 @@ def init_project(target: Path, project_name: str | None = None) -> dict:
global_prompt_path.write_text(_resolve_global_prompt(name, aipass_home, global_prompt_path), encoding="utf-8")
created.append(str(global_prompt_path))
# 2b. .aipass/hooks.json — project hook config from template
hooks_json_path = aipass_dir / "hooks.json"
if not hooks_json_path.exists() and aipass_home:
template = Path(aipass_home) / ".aipass" / "project_hooks.json"
if template.is_file():
shutil.copy2(str(template), str(hooks_json_path))
created.append(str(hooks_json_path))
else:
logger.info("hooks template not found at %s — skipping", template)
# 3-5. CLAUDE.md, AGENTS.md — project templates or AIPass source
for md_name in ("CLAUDE.md", "AGENTS.md"):
dest = target / md_name
@@ -395,11 +398,6 @@ def init_project(target: Path, project_name: str | None = None) -> dict:
prep_path.write_text(sc.prep_md(), encoding="utf-8")
created.append(str(prep_path))
# 9d. Ship enforcement + injector hooks from AIPass install
if aipass_home:
shipped = _ship_hooks(aipass_home, target)
created.extend(shipped)
# 10. src/<project>/ package structure (pip-installable from day one)
package_name = raw_name.lower().replace("-", "_").replace(" ", "_")
src_dir = target / "src"
@@ -531,6 +529,34 @@ def update_project(target: Path) -> dict:
else:
already_current.append(str(settings_path))
# hooks.json — union-merge: preserve user enabled, add new hooks from template
hooks_json_path = aipass_dir / "hooks.json"
hook_home = aipass_home or _detect_aipass_home()
template_path = Path(hook_home) / ".aipass" / "project_hooks.json" if hook_home else None
if template_path and template_path.is_file():
template_data = json.loads(template_path.read_text(encoding="utf-8"))
if hooks_json_path.exists():
try:
existing_hooks = json.loads(hooks_json_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
logger.info("hooks.json parse failed, rebuilding: %s", exc)
existing_hooks = {}
merged_hooks = _merge_hooks_json(existing_hooks, template_data)
merged_hooks_content = json.dumps(merged_hooks, indent=2, ensure_ascii=False) + "\n"
if existing_hooks != merged_hooks:
hooks_json_path.write_text(merged_hooks_content, encoding="utf-8")
updated.append(str(hooks_json_path))
else:
already_current.append(str(hooks_json_path))
else:
hooks_json_path.write_text(
json.dumps(template_data, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
updated.append(str(hooks_json_path))
elif hooks_json_path.exists():
already_current.append(str(hooks_json_path))
# CLAUDE.md, AGENTS.md — sync from project templates or AIPass source
for md_name in ("CLAUDE.md", "AGENTS.md"):
dest = target / md_name
@@ -557,12 +583,6 @@ def update_project(target: Path) -> dict:
else:
already_current.append(str(prep_path))
# Re-sync enforcement + injector hooks from AIPass install
hook_home = aipass_home or _detect_aipass_home()
if hook_home:
shipped = _ship_hooks(hook_home, target)
updated.extend(shipped)
# --- User-owned files: always skip ---
for skip_name in (
str(registry_path),
+21 -27
View File
@@ -6,11 +6,7 @@
# Modified: 2026-04-16
# =============================================
"""
aipass doctor — system health aggregation
Run: aipass doctor [--verbose] [--fix] [--fix --json]
"""
"""aipass doctor — system health aggregation."""
from __future__ import annotations
@@ -60,10 +56,6 @@ from aipass.aipass.apps.handlers.ui.progress import (
make_doctor_progress,
)
# =============================================================================
# TYPES
# =============================================================================
_BRANCH_ROOT = Path(__file__).resolve().parents[2]
@@ -76,9 +68,7 @@ class CheckResult(NamedTuple):
remediation: str
# =============================================================================
# IDENTITY HELPERS
# =============================================================================
# --- Identity helpers ---
def _find_registry() -> Path | None:
@@ -97,9 +87,7 @@ def _find_registry() -> Path | None:
return None
# =============================================================================
# CHECK GROUPS
# =============================================================================
# --- Check groups ---
def _check_system() -> List[CheckResult]:
@@ -201,6 +189,20 @@ def _check_identity() -> List[CheckResult]:
else:
results.append(CheckResult("registry valid", GLYPH_FAIL, "missing 'branches' key", "Re-run 'aipass init'"))
# hooks.json presence (project-level hook config)
hooks_json = reg_path.parent / ".aipass" / "hooks.json"
if hooks_json.exists():
results.append(CheckResult("hooks.json", GLYPH_PASS, "present", ""))
else:
results.append(
CheckResult(
"hooks.json",
GLYPH_WARN,
"not found",
"Run 'aipass init update' to create .aipass/hooks.json",
)
)
# Passport readable
passport = _BRANCH_ROOT / ".trinity" / "passport.json"
if passport.exists():
@@ -478,9 +480,7 @@ def _check_community() -> List[CheckResult]:
return results
# =============================================================================
# STRUCTURE CHECK GROUP
# =============================================================================
# --- Structure check group ---
def _check_structure() -> List[CheckResult]:
@@ -558,9 +558,7 @@ def _check_structure() -> List[CheckResult]:
return results
# =============================================================================
# MAIN DOCTOR RUN
# =============================================================================
# --- Main doctor run ---
def run_doctor(verbose: bool = False, interactive: bool = False, fix: bool = False) -> int:
@@ -620,9 +618,7 @@ def run_doctor(verbose: bool = False, interactive: bool = False, fix: bool = Fal
return error_count
# =============================================================================
# OUTPUT FORMATTING
# =============================================================================
# --- Output formatting ---
def print_introspection() -> None:
@@ -652,9 +648,7 @@ def print_help() -> None:
console.print()
# =============================================================================
# COMMAND HANDLER
# =============================================================================
# --- Command handler ---
def handle_command(command: str, args: list[str]) -> bool:
+176 -56
View File
@@ -22,6 +22,7 @@ import pytest # pyright: ignore[reportMissingImports]
from aipass.aipass.apps.handlers.init import scaffold_content as sc
from aipass.aipass.apps.handlers.init.bootstrap import (
_merge_hooks_json,
_sanitize_name,
init_project,
update_project,
@@ -126,7 +127,7 @@ def test_init_project_creates_all_expected_files(tmp_path):
created_basenames = [Path(f).name for f in result["created_files"]]
for f in expected_files:
assert f.name in created_basenames or f.exists(), f"Expected {f.name} in created_files"
assert len(result["created_files"]) >= 11
assert len(result["created_files"]) >= 12
def test_init_project_return_dict_structure(tmp_path):
@@ -324,7 +325,7 @@ def test_init_project_auto_creates_target_dir(tmp_path):
assert target.is_dir()
assert result["project_name"] == "NESTED"
assert len(result["created_files"]) >= 11
assert len(result["created_files"]) >= 12
def test_init_project_defaults_name_from_directory(tmp_path):
@@ -470,7 +471,7 @@ def test_update_project_already_current_after_init(tmp_path):
result = update_project(target)
assert len(result["updated_files"]) == 0
assert len(result["already_current"]) >= 4
assert len(result["already_current"]) >= 5
def test_update_project_idempotent(tmp_path):
@@ -549,8 +550,8 @@ def test_update_project_creates_missing_managed_dirs(tmp_path):
assert (target / ".aipass" / "aipass_global_prompt.md").exists()
assert (target / ".claude" / "settings.json").exists()
# Managed files in deleted dirs re-written (global_prompt, settings, prep)
assert len(result["updated_files"]) == 3
# Managed files in deleted dirs re-written (global_prompt, hooks.json, settings, prep)
assert len(result["updated_files"]) == 4
assert len(result["already_current"]) >= 2
@@ -629,7 +630,7 @@ def test_update_project_adds_aipass_home_if_missing(tmp_path):
# ---------------------------------------------------------------------------
# DPLAN-0139: Hook shipping + /memo tests
# DPLAN-0190: hooks.json + /memo tests
# ---------------------------------------------------------------------------
@@ -644,8 +645,8 @@ def test_init_project_no_memo_md(tmp_path):
assert not memo_path.exists()
def test_init_project_ships_hooks(tmp_path):
"""init_project copies enforcement + injector hooks to target .claude/hooks/."""
def test_init_project_creates_hooks_json(tmp_path):
"""init_project creates .aipass/hooks.json from project_hooks.json template."""
target = tmp_path / "proj"
target.mkdir()
@@ -654,16 +655,46 @@ def test_init_project_ships_hooks(tmp_path):
if result["aipass_home"] is None:
pytest.skip("AIPASS_HOME not detectable in this environment")
hooks_dir = target / ".claude" / "hooks"
# Post DPLAN-0184: hooks are native handlers in src/aipass/hooks/,
# no longer shipped as script copies. Directory may or may not exist.
if hooks_dir.exists():
shipped = [f.name for f in hooks_dir.iterdir()]
assert len(shipped) == 0, f"No hook scripts should be shipped post-migration: {shipped}"
hooks_json = target / ".aipass" / "hooks.json"
assert hooks_json.exists(), ".aipass/hooks.json should be created"
data = json.loads(hooks_json.read_text(encoding="utf-8"))
assert data.get("hooks_enabled") is True
assert "UserPromptSubmit" in data
def test_init_project_hooks_not_shipped_without_aipass_home(tmp_path, monkeypatch):
"""When AIPASS_HOME is not detectable, hooks are not shipped."""
def test_init_project_hooks_json_matches_template(tmp_path):
"""hooks.json content matches the project_hooks.json template."""
target = tmp_path / "proj"
target.mkdir()
result = init_project(target, project_name="tmpl")
if result["aipass_home"] is None:
pytest.skip("AIPASS_HOME not detectable in this environment")
hooks_json = target / ".aipass" / "hooks.json"
template = Path(result["aipass_home"]) / ".aipass" / "project_hooks.json"
if not template.exists():
pytest.skip("project_hooks.json template not found")
assert hooks_json.read_bytes() == template.read_bytes()
def test_init_project_hooks_json_in_created_files(tmp_path):
"""hooks.json path appears in created_files list."""
target = tmp_path / "proj"
target.mkdir()
result = init_project(target, project_name="created")
if result["aipass_home"] is None:
pytest.skip("AIPASS_HOME not detectable in this environment")
assert any("hooks.json" in f for f in result["created_files"])
def test_init_project_no_hooks_json_without_aipass_home(tmp_path, monkeypatch):
"""When AIPASS_HOME is not detectable, hooks.json is not created."""
target = tmp_path / "proj"
target.mkdir()
@@ -674,55 +705,20 @@ def test_init_project_hooks_not_shipped_without_aipass_home(tmp_path, monkeypatc
init_project(target, project_name="nohooks")
hooks_dir = target / ".claude" / "hooks"
assert not hooks_dir.exists() or len(list(hooks_dir.iterdir())) == 0
assert not (target / ".aipass" / "hooks.json").exists()
def test_init_project_no_audio_hooks_shipped(tmp_path):
"""Audio hooks (notification_sound, tool_use_sound, stop_sound) are never shipped."""
def test_init_project_no_hook_scripts_shipped(tmp_path):
"""No hook scripts are shipped to .claude/hooks/ (engine runs from $AIPASS_HOME)."""
target = tmp_path / "proj"
target.mkdir()
init_project(target, project_name="noaudio")
init_project(target, project_name="noscripts")
hooks_dir = target / ".claude" / "hooks"
if hooks_dir.exists():
shipped = [f.name for f in hooks_dir.iterdir()]
assert "notification_sound.py" not in shipped
assert "tool_use_sound.py" not in shipped
assert "stop_sound.py" not in shipped
def test_update_project_resyncs_hooks(tmp_path):
"""update_project re-copies hooks when source differs from target."""
target = tmp_path / "proj"
target.mkdir()
result = init_project(target, project_name="resync")
if result["aipass_home"] is None:
pytest.skip("AIPASS_HOME not detectable in this environment")
# Post DPLAN-0184: hooks are native handlers, not shipped as copies.
# update_project no longer resyncs hook scripts.
result = update_project(target)
hooks_marker = str(Path(".claude") / "hooks")
hook_paths = [f for f in result["updated_files"] if hooks_marker in f]
assert len(hook_paths) == 0, "No hook scripts should be shipped post-migration"
def test_init_project_hooks_idempotent_on_rerun(tmp_path):
"""Re-running init does not create hook script copies."""
target = tmp_path / "proj"
target.mkdir()
result1 = init_project(target, project_name="idem")
if result1["aipass_home"] is None:
pytest.skip("AIPASS_HOME not detectable in this environment")
hooks_marker = str(Path(".claude") / "hooks")
hook_paths = [f for f in result1["created_files"] if hooks_marker in f]
assert len(hook_paths) == 0, "No hook scripts should be shipped post-migration"
assert len(shipped) == 0, f"No hook scripts should be shipped: {shipped}"
def test_init_project_settings_has_no_hook_events(tmp_path):
@@ -736,6 +732,130 @@ def test_init_project_settings_has_no_hook_events(tmp_path):
assert "hooks" not in settings
def test_update_project_creates_hooks_json_if_missing(tmp_path):
"""update_project creates hooks.json from template when missing."""
target = tmp_path / "proj"
target.mkdir()
registry_data = {
"metadata": {
"id": "test-id",
"name": "MISS",
"version": "1.0.0",
"created": "2026-01-01",
"last_updated": "2026-01-01",
"total_branches": 0,
},
"branches": [],
}
(target / "MISS_REGISTRY.json").write_text(json.dumps(registry_data), encoding="utf-8")
hooks_json = target / ".aipass" / "hooks.json"
assert not hooks_json.exists()
result = update_project(target)
if result["aipass_home"] is None:
pytest.skip("AIPASS_HOME not detectable in this environment")
assert hooks_json.exists()
assert any("hooks.json" in f for f in result["updated_files"])
data = json.loads(hooks_json.read_text(encoding="utf-8"))
assert data.get("hooks_enabled") is True
def test_update_project_union_merge_preserves_user_enabled(tmp_path):
"""update preserves user's enabled=false for existing hooks."""
target = tmp_path / "proj"
target.mkdir()
result = init_project(target, project_name="merge")
if result["aipass_home"] is None:
pytest.skip("AIPASS_HOME not detectable in this environment")
hooks_json = target / ".aipass" / "hooks.json"
data = json.loads(hooks_json.read_text(encoding="utf-8"))
data["PreToolUse"]["git_gate"]["enabled"] = False
hooks_json.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
update_project(target)
updated = json.loads(hooks_json.read_text(encoding="utf-8"))
assert updated["PreToolUse"]["git_gate"]["enabled"] is False
def test_update_project_union_merge_adds_new_hooks(tmp_path):
"""update adds hooks from template that user doesn't have."""
existing = {
"hooks_enabled": True,
"UserPromptSubmit": {
"identity_injector": {"enabled": True, "handler": "old.handler", "matcher": ""},
},
}
template = {
"hooks_enabled": True,
"UserPromptSubmit": {
"identity_injector": {"enabled": True, "handler": "new.handler", "matcher": ""},
"brand_new_hook": {"enabled": True, "handler": "brand.new", "matcher": ""},
},
"Stop": {
"stop_sound": {"enabled": True, "handler": "stop.handler", "matcher": ""},
},
}
merged = _merge_hooks_json(existing, template)
assert "brand_new_hook" in merged["UserPromptSubmit"]
assert "Stop" in merged
assert merged["Stop"]["stop_sound"]["enabled"] is True
def test_update_project_union_merge_preserves_user_hooks():
"""User's custom hooks not in template are kept."""
existing = {
"hooks_enabled": True,
"UserPromptSubmit": {
"my_custom_hook": {"enabled": True, "handler": "custom.handler", "matcher": ""},
},
}
template = {
"hooks_enabled": True,
"UserPromptSubmit": {
"identity_injector": {"enabled": True, "handler": "std.handler", "matcher": ""},
},
}
merged = _merge_hooks_json(existing, template)
assert "my_custom_hook" in merged["UserPromptSubmit"]
assert "identity_injector" in merged["UserPromptSubmit"]
def test_merge_hooks_json_preserves_hooks_enabled_false():
"""User's hooks_enabled=false is preserved over template's true."""
existing = {"hooks_enabled": False}
template = {"hooks_enabled": True, "Stop": {"s": {"enabled": True, "handler": "h", "matcher": ""}}}
merged = _merge_hooks_json(existing, template)
assert merged["hooks_enabled"] is False
def test_update_project_hooks_json_already_current(tmp_path):
"""update reports hooks.json as already_current when unchanged."""
target = tmp_path / "proj"
target.mkdir()
result = init_project(target, project_name="curr")
if result["aipass_home"] is None:
pytest.skip("AIPASS_HOME not detectable in this environment")
result = update_project(target)
assert not any("hooks.json" in f for f in result["updated_files"])
assert any("hooks.json" in f for f in result["already_current"])
# ---------------------------------------------------------------------------
# scaffold_content — global_prompt_md tests
# ---------------------------------------------------------------------------
+44 -5
View File
@@ -441,10 +441,8 @@ class TestProviderManifest:
manifest = tmp_path / ".claude" / "provider_manifest.json"
manifest.parent.mkdir(parents=True)
cmd_a = "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Stop"
cmd_b = (
"$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Notification"
)
cmd_a = "$AIPASS_HOME/bin/hook-bridge Stop"
cmd_b = "$AIPASS_HOME/bin/hook-bridge Notification"
manifest.write_text(
json.dumps(
{
@@ -487,7 +485,7 @@ class TestProviderManifest:
manifest = tmp_path / ".claude" / "provider_manifest.json"
manifest.parent.mkdir(parents=True)
cmd = "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Stop"
cmd = "$AIPASS_HOME/bin/hook-bridge Stop"
manifest.write_text(
json.dumps(
{
@@ -573,3 +571,44 @@ class TestProviderManifest:
result = _find_manifest()
assert result is not None
assert result == manifest
# =============================================================================
# TestHooksJsonCheck
# =============================================================================
class TestHooksJsonCheck:
"""Tests for hooks.json presence check in _check_identity (DPLAN-0190)."""
def test_hooks_json_present_returns_pass(self, tmp_path) -> None:
"""When .aipass/hooks.json exists, check returns PASS."""
from aipass.aipass.apps.modules.doctor import _check_identity
registry = tmp_path / "TEST_REGISTRY.json"
registry.write_text(json.dumps({"metadata": {"id": "t"}, "branches": []}), encoding="utf-8")
hooks_dir = tmp_path / ".aipass"
hooks_dir.mkdir()
(hooks_dir / "hooks.json").write_text('{"hooks_enabled": true}', encoding="utf-8")
with patch("aipass.aipass.apps.modules.doctor._find_registry", return_value=registry):
results = _check_identity()
hooks_results = [r for r in results if r.label == "hooks.json"]
assert len(hooks_results) == 1
assert hooks_results[0].glyph == GLYPH_PASS
def test_hooks_json_missing_returns_warn(self, tmp_path) -> None:
"""When .aipass/hooks.json is absent, check returns WARN."""
from aipass.aipass.apps.modules.doctor import _check_identity
registry = tmp_path / "TEST_REGISTRY.json"
registry.write_text(json.dumps({"metadata": {"id": "t"}, "branches": []}), encoding="utf-8")
with patch("aipass.aipass.apps.modules.doctor._find_registry", return_value=registry):
results = _check_identity()
hooks_results = [r for r in results if r.label == "hooks.json"]
assert len(hooks_results) == 1
assert hooks_results[0].glyph == GLYPH_WARN
assert "init update" in hooks_results[0].remediation
+2
View File
@@ -12,3 +12,5 @@ build/
*.log
*.tmp
*.swp
test_devto_driver.py
test_bluesky_driver.py
@@ -33,6 +33,7 @@ Task belongs to specialist domain → ask them. Investigate/fix small things you
| Command routing | @drone | @branch resolution, subprocess |
| Memory, vectors | @memory | ChromaDB, search, archival |
| User onboarding, init | @aipass | Concierge, aipass init, doctor, scanner |
| Hooks, engine, gates | @hooks | Hook engine, bridges, per-project config, sound |
## Git — Dev Branch, Drone Only, You Are Gatekeeper
@@ -82,9 +83,9 @@ drone @flow list open # active plans
drone systems # all branches
```
## 12 Core Branches
## 13 Core Branches
drone, seedgo, prax, cli, ai_mail, api, flow, spawn, trigger, memory, aipass, devpulse (you — coordinates via dispatch+agents)
drone, seedgo, prax, cli, ai_mail, api, flow, spawn, trigger, memory, aipass, hooks, devpulse (you — coordinates via dispatch+agents)
## Working Habits
+185 -389
View File
@@ -1,398 +1,194 @@
{
"metadata": {
"version": "1.1.0",
"version": "2.0.0",
"created": "2026-05-18",
"updated": "2026-05-21",
"description": "Standards bypass configuration for this branch"
"updated": "2026-05-28",
"description": "Standards bypass configuration for this branch",
"audit_context": "DPLAN-0191: Full ownership hardening. All handler wiring verified against .aipass/hooks.json + engine.jsonl firing evidence. Dynamic dispatch via engine._run_handler (importlib.import_module + getattr) means handlers are never statically imported — seedgo's dead_code/unused_function checks are false positives for this architecture."
},
"bypass": [
{
"file": "apps/modules/engine.py",
"standard": "json_structure",
"reason": "Engine uses JSONL diagnostic logging, not branch json_handler — different purpose"
},
{
"file": "apps/handlers/bridges/claude.py",
"standard": "handlers",
"reason": "Bridges import engine module by design — that is their entire purpose"
},
{
"file": "apps/handlers/bridges/claude.py",
"standard": "json_structure",
"reason": "Thin entry point, no JSON operations to log"
},
{
"file": "apps/handlers/bridges/claude.py",
"standard": "dead_code",
"reason": "Bridge called externally by provider settings subprocess — no internal import"
},
{
"file": "apps/handlers/bridges/claude.py",
"standard": "architecture",
"reason": "Bridge importing engine module is its architectural purpose"
},
{
"file": "apps/handlers/bridges/claude.py",
"standard": "imports",
"reason": "Bridge imports engine module by design — sole purpose"
},
{
"file": "apps/hooks.py",
"standard": "unused_function",
"reason": "print_introspection() called by drone's discovery system, not internal code"
},
{
"file": "apps/handlers/config/loader.py",
"standard": "json_structure",
"reason": "Config loader does $AIPASS_HOME variable expansion before JSON parse — json_handler does not support this"
},
{
"file": "apps/handlers/config/diagnostics.py",
"standard": "json_structure",
"reason": "JSONL append-only diagnostic log — different pattern from branch json_handler storage"
},
{
"file": "apps/handlers/notification/tool_sound.py",
"standard": "json_structure",
"reason": "Sound handler — no JSON operations, plays WAV files"
},
{
"file": "tests/conftest.py",
"standard": "architecture",
"reason": "Test fixtures live in tests/, not in the 3-layer apps structure"
},
{
"file": "tests/test_tool_sound.py",
"standard": "architecture",
"reason": "Test files live in tests/, not in the 3-layer apps structure"
},
{
"file": "tests/test_tool_sound.py",
"standard": "documentation",
"reason": "Test methods use descriptive names as documentation per pytest convention"
},
{
"file": "tests/test_tool_sound.py",
"standard": "encapsulation",
"reason": "Tests import handlers directly to test implementation details"
},
{
"file": "tests/test_tool_sound.py",
"standard": "meta",
"reason": "Test files do not need Version/Modified metadata headers"
},
{
"file": "tests/test_engine.py",
"standard": "architecture",
"reason": "Test files live in tests/, not in the 3-layer apps structure"
},
{
"file": "tests/test_engine.py",
"standard": "encapsulation",
"reason": "Tests import handlers directly to test implementation details"
},
{
"file": "tests/test_engine.py",
"standard": "documentation",
"reason": "Test methods use descriptive names as documentation per pytest convention"
},
{
"file": "tests/test_engine.py",
"standard": "help_text",
"reason": "Test data contains command references as part of test fixtures, not user-facing help"
},
{
"file": "tests/test_engine.py",
"standard": "json_handler",
"reason": "Hooks branch does not use json_handler — has its own JSONL logging"
},
{
"file": "tests/test_engine.py",
"standard": "exception_contracts",
"reason": "Hooks has no json_handler create_default/save_invalid/invalid_mode patterns"
},
{
"file": "apps/handlers/notification/announce.py",
"standard": "json_structure",
"reason": "Sound handler — no JSON operations, plays WAV files"
},
{
"file": "apps/handlers/notification/stop_sound.py",
"standard": "json_structure",
"reason": "Sound handler — no JSON operations, plays WAV files"
},
{
"file": "tests/test_stop_sound.py",
"standard": "architecture",
"reason": "Test files live in tests/, not in the 3-layer apps structure"
},
{
"file": "tests/test_stop_sound.py",
"standard": "documentation",
"reason": "Test methods use descriptive names as documentation per pytest convention"
},
{
"file": "tests/test_stop_sound.py",
"standard": "encapsulation",
"reason": "Tests import handlers directly to test implementation details"
},
{
"file": "tests/test_stop_sound.py",
"standard": "meta",
"reason": "Test files do not need Version/Modified metadata headers"
},
{
"file": "tests/test_announce.py",
"standard": "architecture",
"reason": "Test files live in tests/, not in the 3-layer apps structure"
},
{
"file": "tests/test_announce.py",
"standard": "documentation",
"reason": "Test methods use descriptive names as documentation per pytest convention"
},
{
"file": "tests/test_announce.py",
"standard": "encapsulation",
"reason": "Tests import handlers directly to test implementation details"
},
{
"file": "tests/test_announce.py",
"standard": "meta",
"reason": "Test files do not need Version/Modified metadata headers"
},
{
"file": "apps/handlers/notification/email.py",
"standard": "json_structure",
"reason": "Uses stdlib json.loads for inbox parsing — no JSON file ops needing json_handler"
},
{
"file": "apps/handlers/lifecycle/auto_watchdog.py",
"standard": "json_structure",
"reason": "Uses stdlib json.dumps to produce additionalContext output — no JSON file ops needing json_handler"
},
{
"file": "apps/handlers/lifecycle/auto_fix.py",
"standard": "json_structure",
"reason": "Diagnostics handler uses stdlib json for hook protocol responses and state file — no JSON file ops needing json_handler"
},
{
"file": "apps/handlers/security/edit_gate.py",
"standard": "json_structure",
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler"
},
{
"file": "apps/handlers/security/git_gate.py",
"standard": "json_structure",
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler"
},
{
"file": "apps/handlers/security/subagent_gate.py",
"standard": "json_structure",
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler"
},
{
"file": "apps/handlers/security/subagent_gate.py",
"standard": "open_encoding",
"reason": "NamedTemporaryFile creates binary wav for Piper TTS — encoding not applicable to binary audio"
},
{
"file": "tests/test_email.py",
"standard": "architecture",
"reason": "Test files live in tests/, not in the 3-layer apps structure"
},
{
"file": "tests/test_email.py",
"standard": "documentation",
"reason": "Test methods use descriptive names as documentation per pytest convention"
},
{
"file": "tests/test_email.py",
"standard": "encapsulation",
"reason": "Tests import handlers directly to test implementation details"
},
{
"file": "tests/test_email.py",
"standard": "meta",
"reason": "Test files do not need Version/Modified metadata headers"
},
{
"file": "tests/test_subagent_gate.py",
"standard": "architecture",
"reason": "Test files live in tests/, not in the 3-layer apps structure"
},
{
"file": "tests/test_subagent_gate.py",
"standard": "documentation",
"reason": "Test methods use descriptive names as documentation per pytest convention"
},
{
"file": "tests/test_subagent_gate.py",
"standard": "encapsulation",
"reason": "Tests import handlers directly to test implementation details"
},
{
"file": "tests/test_subagent_gate.py",
"standard": "meta",
"reason": "Test files do not need Version/Modified metadata headers"
},
{
"file": "tests/test_auto_fix.py",
"standard": "architecture",
"reason": "Test files live in tests/, not in the 3-layer apps structure"
},
{
"file": "tests/test_auto_fix.py",
"standard": "documentation",
"reason": "Test methods use descriptive names as documentation per pytest convention"
},
{
"file": "tests/test_auto_fix.py",
"standard": "encapsulation",
"reason": "Tests import handlers directly to test implementation details"
},
{
"file": "tests/test_auto_fix.py",
"standard": "meta",
"reason": "Test files do not need Version/Modified metadata headers"
},
{
"file": "tests/test_auto_fix.py",
"standard": "commented_logger",
"reason": "Test data contains '# logger.debug(msg)' as input to pattern checker under test — not a commented-out call"
},
{
"file": "tests/test_auto_fix.py",
"standard": "trigger",
"reason": "Test cleanup .unlink() removes temporary state files — not a production file deletion"
},
{
"file": "apps/handlers/prompt/identity.py",
"standard": "json_structure",
"reason": "Uses stdlib json.loads to read passport.json — no JSON file ops needing json_handler"
},
{
"file": "tests/test_identity.py",
"standard": "architecture",
"reason": "Test files live in tests/, not in the 3-layer apps structure"
},
{
"file": "tests/test_identity.py",
"standard": "documentation",
"reason": "Test methods use descriptive names as documentation per pytest convention"
},
{
"file": "tests/test_identity.py",
"standard": "encapsulation",
"reason": "Tests import handlers directly to test implementation details"
},
{
"file": "tests/test_identity.py",
"standard": "meta",
"reason": "Test files do not need Version/Modified metadata headers"
},
{
"file": "apps/handlers/prompt/branch_loader.py",
"standard": "json_structure",
"reason": "No JSON operations — reads markdown files and outputs text"
},
{
"file": "tests/test_branch_loader.py",
"standard": "architecture",
"reason": "Test files live in tests/, not in the 3-layer apps structure"
},
{
"file": "tests/test_branch_loader.py",
"standard": "documentation",
"reason": "Test methods use descriptive names as documentation per pytest convention"
},
{
"file": "tests/test_branch_loader.py",
"standard": "encapsulation",
"reason": "Tests import handlers directly to test implementation details"
},
{
"file": "tests/test_branch_loader.py",
"standard": "meta",
"reason": "Test files do not need Version/Modified metadata headers"
},
{
"file": "apps/handlers/prompt/global_loader.py",
"standard": "json_structure",
"reason": "No JSON operations — reads markdown file and outputs text"
},
{
"file": "tests/test_global_loader.py",
"standard": "architecture",
"reason": "Test files live in tests/, not in the 3-layer apps structure"
},
{
"file": "tests/test_global_loader.py",
"standard": "documentation",
"reason": "Test methods use descriptive names as documentation per pytest convention"
},
{
"file": "tests/test_global_loader.py",
"standard": "encapsulation",
"reason": "Tests import handlers directly to test implementation details"
},
{
"file": "tests/test_global_loader.py",
"standard": "meta",
"reason": "Test files do not need Version/Modified metadata headers"
},
{
"file": "apps/handlers/lifecycle/compact.py",
"standard": "json_structure",
"reason": "Uses stdlib json.loads for local.json reading — no JSON file ops needing json_handler"
},
{
"file": "apps/handlers/lifecycle/rollover.py",
"standard": "json_structure",
"reason": "Uses stdlib json.loads for registry and memory file checks — no JSON file ops needing json_handler"
},
{
"file": "tests/test_compact.py",
"standard": "architecture",
"reason": "Test files live in tests/, not in the 3-layer apps structure"
},
{
"file": "tests/test_compact.py",
"standard": "documentation",
"reason": "Test methods use descriptive names as documentation per pytest convention"
},
{
"file": "tests/test_compact.py",
"standard": "encapsulation",
"reason": "Tests import handlers directly to test implementation details"
},
{
"file": "tests/test_compact.py",
"standard": "meta",
"reason": "Test files do not need Version/Modified metadata headers"
},
{
"file": "tests/test_rollover.py",
"standard": "architecture",
"reason": "Test files live in tests/, not in the 3-layer apps structure"
},
{
"file": "tests/test_rollover.py",
"standard": "documentation",
"reason": "Test methods use descriptive names as documentation per pytest convention"
},
{
"file": "tests/test_rollover.py",
"standard": "encapsulation",
"reason": "Tests import handlers directly to test implementation details"
},
{
"file": "tests/test_rollover.py",
"standard": "meta",
"reason": "Test files do not need Version/Modified metadata headers"
}
{"standard": "dead_code", "reason": "All 15 handler files under apps/handlers/ are invoked dynamically by engine._run_handler (engine.py:60-66) via importlib.import_module + getattr on handler path strings from .aipass/hooks.json. They are never statically imported by design. Each handler verified wired in hooks.json AND fired in engine.jsonl (DPLAN-0191). Follow-up for @seedgo: teach dead_code checker about importlib dynamic dispatch patterns."},
{"file": "apps/handlers/bridges/claude.py", "standard": "dead_code", "reason": "Bridge called externally by provider settings subprocess — no internal import. Verified wired in ~/.claude/settings.json hook entries."},
{"file": "apps/handlers/bridges/claude.py", "standard": "unused_function", "reason": "main() called as subprocess entry point from provider settings — never statically imported."},
{"file": "apps/handlers/bridges/claude.py", "standard": "handlers", "reason": "Bridges import engine module by design — that is their entire purpose."},
{"file": "apps/handlers/bridges/claude.py", "standard": "json_structure", "reason": "Thin entry point, no JSON operations to log."},
{"file": "apps/handlers/bridges/claude.py", "standard": "architecture", "reason": "Bridge importing engine module is its architectural purpose."},
{"file": "apps/handlers/bridges/claude.py", "standard": "imports", "reason": "Bridge imports engine module by design — sole purpose."},
{"file": "apps/handlers/prompt/identity.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.prompt.identity.handle' — not statically imported by design. Verified wired in UserPromptSubmit.identity_injector + fires in engine.jsonl."},
{"file": "apps/handlers/prompt/identity.py", "standard": "unused_function", "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Verified fires in engine.jsonl (UserPromptSubmit.identity_injector)."},
{"file": "apps/handlers/prompt/identity.py", "standard": "json_structure", "reason": "Uses stdlib json.loads to read passport.json — no JSON file ops needing json_handler."},
{"file": "apps/handlers/prompt/branch_loader.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.prompt.branch_loader.handle' — not statically imported by design. Verified wired in UserPromptSubmit.branch_prompt + fires in engine.jsonl."},
{"file": "apps/handlers/prompt/branch_loader.py", "standard": "unused_function", "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Verified fires in engine.jsonl (UserPromptSubmit.branch_prompt)."},
{"file": "apps/handlers/prompt/branch_loader.py", "standard": "json_structure", "reason": "No JSON operations — reads markdown files and outputs text."},
{"file": "apps/handlers/prompt/global_loader.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.prompt.global_loader.handle' — not statically imported by design. Verified wired in UserPromptSubmit.global_prompt + fires in engine.jsonl."},
{"file": "apps/handlers/prompt/global_loader.py", "standard": "unused_function", "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Verified fires in engine.jsonl (UserPromptSubmit.global_prompt)."},
{"file": "apps/handlers/prompt/global_loader.py", "standard": "json_structure", "reason": "No JSON operations — reads markdown file and outputs text."},
{"file": "apps/handlers/security/edit_gate.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.edit_gate.handle' — not statically imported by design. Verified wired in PreToolUse.pre_edit_gate + fires in engine.jsonl."},
{"file": "apps/handlers/security/edit_gate.py", "standard": "unused_function", "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Verified fires in engine.jsonl (PreToolUse.pre_edit_gate)."},
{"file": "apps/handlers/security/edit_gate.py", "standard": "json_structure", "reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler."},
{"file": "apps/handlers/security/git_gate.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.git_gate.handle' — not statically imported by design. Verified wired in PreToolUse.git_gate + fires in engine.jsonl."},
{"file": "apps/handlers/security/git_gate.py", "standard": "unused_function", "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Verified fires in engine.jsonl (PreToolUse.git_gate)."},
{"file": "apps/handlers/security/git_gate.py", "standard": "json_structure", "reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler."},
{"file": "apps/handlers/security/subagent_gate.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.subagent_gate.handle' — not statically imported by design. Verified wired in SubagentStop.subagent_stop_gate + fires in engine.jsonl."},
{"file": "apps/handlers/security/subagent_gate.py", "standard": "unused_function", "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Verified fires in engine.jsonl (SubagentStop.subagent_stop_gate)."},
{"file": "apps/handlers/security/subagent_gate.py", "standard": "json_structure", "reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler."},
{"file": "apps/handlers/security/subagent_gate.py", "standard": "open_encoding", "reason": "NamedTemporaryFile creates binary wav for Piper TTS — encoding not applicable to binary audio."},
{"file": "apps/handlers/lifecycle/auto_fix.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_fix.handle' — not statically imported by design. Verified wired in PostToolUse.auto_fix_diagnostics + fires in engine.jsonl."},
{"file": "apps/handlers/lifecycle/auto_fix.py", "standard": "unused_function", "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Verified fires in engine.jsonl (PostToolUse.auto_fix_diagnostics)."},
{"file": "apps/handlers/lifecycle/auto_fix.py", "standard": "json_structure", "reason": "Diagnostics handler uses stdlib json for hook protocol responses and state file — no JSON file ops needing json_handler."},
{"file": "apps/handlers/lifecycle/auto_watchdog.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_watchdog.handle' — not statically imported by design. Verified wired in PostToolUse.auto_watchdog + fires in engine.jsonl."},
{"file": "apps/handlers/lifecycle/auto_watchdog.py", "standard": "unused_function", "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Verified fires in engine.jsonl (PostToolUse.auto_watchdog)."},
{"file": "apps/handlers/lifecycle/auto_watchdog.py", "standard": "json_structure", "reason": "Uses stdlib json.dumps to produce additionalContext output — no JSON file ops needing json_handler."},
{"file": "apps/handlers/lifecycle/compact.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.compact.handle' — not statically imported by design. Verified wired in PreCompact.pre_compact (PreCompact events are rare — fires only during context compaction)."},
{"file": "apps/handlers/lifecycle/compact.py", "standard": "unused_function", "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in PreCompact.pre_compact — fires during compaction events."},
{"file": "apps/handlers/lifecycle/compact.py", "standard": "json_structure", "reason": "Uses stdlib json.loads for local.json reading — no JSON file ops needing json_handler."},
{"file": "apps/handlers/lifecycle/rollover.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.rollover.handle' — not statically imported by design. Verified wired in PreCompact.pre_compact_rollover (PreCompact events are rare — fires only during context compaction)."},
{"file": "apps/handlers/lifecycle/rollover.py", "standard": "unused_function", "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in PreCompact.pre_compact_rollover — fires during compaction events."},
{"file": "apps/handlers/lifecycle/rollover.py", "standard": "json_structure", "reason": "Uses stdlib json.loads for registry and memory file checks — no JSON file ops needing json_handler."},
{"file": "apps/handlers/notification/announce.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.announce.handle' — not statically imported by design. Verified wired in Notification.notification_sound + fires in engine.jsonl."},
{"file": "apps/handlers/notification/announce.py", "standard": "unused_function", "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Verified fires in engine.jsonl (Notification.notification_sound)."},
{"file": "apps/handlers/notification/announce.py", "standard": "json_structure", "reason": "Sound handler — no JSON operations, plays WAV files."},
{"file": "apps/handlers/notification/email.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.email.handle' — not statically imported by design. Verified wired in UserPromptSubmit.email_notification + fires in engine.jsonl."},
{"file": "apps/handlers/notification/email.py", "standard": "unused_function", "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Verified fires in engine.jsonl (UserPromptSubmit.email_notification)."},
{"file": "apps/handlers/notification/email.py", "standard": "json_structure", "reason": "Uses stdlib json.loads for inbox parsing — no JSON file ops needing json_handler."},
{"file": "apps/handlers/notification/stop_sound.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.stop_sound.handle' — not statically imported by design. Verified wired in Stop.stop_sound + fires in engine.jsonl."},
{"file": "apps/handlers/notification/stop_sound.py", "standard": "unused_function", "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Verified fires in engine.jsonl (Stop.stop_sound)."},
{"file": "apps/handlers/notification/stop_sound.py", "standard": "json_structure", "reason": "Sound handler — no JSON operations, plays WAV files."},
{"file": "apps/handlers/notification/tool_sound.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.tool_sound.handle' — not statically imported by design. Verified wired in PreToolUse.tool_use_sound + fires in engine.jsonl."},
{"file": "apps/handlers/notification/tool_sound.py", "standard": "unused_function", "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Verified fires in engine.jsonl (PreToolUse.tool_use_sound)."},
{"file": "apps/handlers/notification/tool_sound.py", "standard": "json_structure", "reason": "Sound handler — no JSON operations, plays WAV files."},
{"file": "apps/handlers/config/loader.py", "standard": "json_structure", "reason": "Config loader does $AIPASS_HOME variable expansion before JSON parse — json_handler does not support this."},
{"file": "apps/handlers/config/diagnostics.py", "standard": "json_structure", "reason": "JSONL append-only diagnostic log — different pattern from branch json_handler storage."},
{"file": "apps/modules/engine.py", "standard": "json_structure", "reason": "Engine uses JSONL diagnostic logging, not branch json_handler — different purpose."},
{"file": "apps/modules/engine.py", "standard": "modules", "reason": "dispatch() is the engine's core purpose — it IS the module's primary function, not a handler that belongs elsewhere. The engine exists to dispatch; moving dispatch to handlers/ would leave an empty module."},
{"file": "apps/modules/hooksound.py", "standard": "json_structure", "reason": "Sound mute toggle — touches /tmp/aipass-hooks-muted flag file only, no JSON operations or json_handler storage."},
{"file": "apps/modules/hooksound.py", "standard": "trigger", "reason": "MUTE_FLAG.unlink() removes a /tmp mute flag file for sound toggle — not a tracked resource or production data deletion. Deliberate user action via 'drone @hooks hooksound on'."},
{"file": "apps/modules/hookstatus.py", "standard": "json_structure", "reason": "Read-only config viewer — delegates JSON loading to config/loader.py, no direct JSON file ops."},
{"file": "apps/hooks.py", "standard": "unused_function", "reason": "print_introspection() called by drone's discovery system, not internal code."},
{"file": "apps/sound.py", "standard": "unused_function", "reason": "play() called by handler files (stop_sound.py, announce.py) that are dynamically dispatched via importlib — static analysis cannot trace the call chain from hooks.json → engine → handler → sound.play()."},
{"standard": "test_quality", "reason": "Hooks branch does not use json_handler — has its own JSONL diagnostic logging (diagnostics.py) and stdlib json for hook protocol I/O. json_handler coverage, mock_json_handler fixture, and exception_contracts (create_default_raises, save_invalid_raises, invalid_mode_raises) are all N/A for a hook dispatch engine architecture."},
{"file": "tests/conftest.py", "standard": "architecture", "reason": "Test fixtures live in tests/, not in the 3-layer apps structure."},
{"file": "tests/conftest.py", "standard": "json_handler", "reason": "Hooks branch does not use json_handler — has its own JSONL logging and stdlib json for hook protocol. mock_json_handler fixture is N/A."},
{"file": "tests/conftest.py", "standard": "exception_contracts", "reason": "Hooks has no json_handler create_default/save_invalid/invalid_mode patterns — those contracts are N/A for a hook dispatch engine."},
{"file": "tests/test_engine.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."},
{"file": "tests/test_engine.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."},
{"file": "tests/test_engine.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
{"file": "tests/test_engine.py", "standard": "help_text", "reason": "Test data contains command references as part of test fixtures, not user-facing help."},
{"file": "tests/test_engine.py", "standard": "json_handler", "reason": "Hooks branch does not use json_handler — has its own JSONL logging."},
{"file": "tests/test_engine.py", "standard": "exception_contracts", "reason": "Hooks has no json_handler create_default/save_invalid/invalid_mode patterns."},
{"file": "tests/test_tool_sound.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."},
{"file": "tests/test_tool_sound.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
{"file": "tests/test_tool_sound.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."},
{"file": "tests/test_tool_sound.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."},
{"file": "tests/test_stop_sound.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."},
{"file": "tests/test_stop_sound.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
{"file": "tests/test_stop_sound.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."},
{"file": "tests/test_stop_sound.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."},
{"file": "tests/test_announce.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."},
{"file": "tests/test_announce.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
{"file": "tests/test_announce.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."},
{"file": "tests/test_announce.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."},
{"file": "tests/test_email.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."},
{"file": "tests/test_email.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
{"file": "tests/test_email.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."},
{"file": "tests/test_email.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."},
{"file": "tests/test_subagent_gate.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."},
{"file": "tests/test_subagent_gate.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
{"file": "tests/test_subagent_gate.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."},
{"file": "tests/test_subagent_gate.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."},
{"file": "tests/test_auto_fix.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."},
{"file": "tests/test_auto_fix.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
{"file": "tests/test_auto_fix.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."},
{"file": "tests/test_auto_fix.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."},
{"file": "tests/test_auto_fix.py", "standard": "commented_logger", "reason": "Test data contains '# logger.debug(msg)' as input to pattern checker under test — not a commented-out call."},
{"file": "tests/test_auto_fix.py", "standard": "trigger", "reason": "Test cleanup .unlink() removes temporary state files — not a production file deletion."},
{"file": "tests/test_identity.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."},
{"file": "tests/test_identity.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
{"file": "tests/test_identity.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."},
{"file": "tests/test_identity.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."},
{"file": "tests/test_branch_loader.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."},
{"file": "tests/test_branch_loader.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
{"file": "tests/test_branch_loader.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."},
{"file": "tests/test_branch_loader.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."},
{"file": "tests/test_global_loader.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."},
{"file": "tests/test_global_loader.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
{"file": "tests/test_global_loader.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."},
{"file": "tests/test_global_loader.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."},
{"file": "tests/test_compact.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."},
{"file": "tests/test_compact.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
{"file": "tests/test_compact.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."},
{"file": "tests/test_compact.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."},
{"file": "tests/test_rollover.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."},
{"file": "tests/test_rollover.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
{"file": "tests/test_rollover.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."},
{"file": "tests/test_rollover.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."},
{"file": "tests/test_hookstatus.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."},
{"file": "tests/test_hookstatus.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
{"file": "tests/test_hookstatus.py", "standard": "encapsulation", "reason": "Tests import modules directly to test implementation details."},
{"file": "tests/test_hookstatus.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."},
{"file": "tests/test_hooksound.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."},
{"file": "tests/test_hooksound.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
{"file": "tests/test_hooksound.py", "standard": "encapsulation", "reason": "Tests import modules directly to test implementation details."},
{"file": "tests/test_hooksound.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."},
{"file": "tests/test_auto_watchdog.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."},
{"file": "tests/test_auto_watchdog.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
{"file": "tests/test_auto_watchdog.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."},
{"file": "tests/test_auto_watchdog.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."},
{"file": "tests/test_edit_gate.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."},
{"file": "tests/test_edit_gate.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
{"file": "tests/test_edit_gate.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."},
{"file": "tests/test_edit_gate.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."},
{"file": "tests/test_git_gate.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."},
{"file": "tests/test_git_gate.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
{"file": "tests/test_git_gate.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."},
{"file": "tests/test_git_gate.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."},
{"file": "tests/test_sound.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."},
{"file": "tests/test_sound.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
{"file": "tests/test_sound.py", "standard": "encapsulation", "reason": "Tests import sound module directly to test implementation details."},
{"file": "tests/test_sound.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."}
],
"notes": {
"removed_2026-05-19": "Stripped 4 illegitimate bypasses — hooks.py/cli, hooks.py/cli_flags, engine.py/modules, engine.py/introspection. Code fixed to meet standards instead."
"removed_2026-05-19": "Stripped 4 illegitimate bypasses — hooks.py/cli, hooks.py/cli_flags, engine.py/modules, engine.py/introspection. Code fixed to meet standards instead.",
"dplan_0191_2026-05-28": "Added dead_code + unused_function bypasses for all 15 dynamically-dispatched handlers after verifying each is wired in .aipass/hooks.json AND fires in engine.jsonl. Root cause: engine._run_handler (engine.py:60-66) uses importlib.import_module + getattr on hooks.json handler strings — handlers are never statically imported. Follow-up for @seedgo: teach dead_code/unused_function about dynamic importlib dispatch patterns."
}
}
+57 -12
View File
@@ -2,9 +2,9 @@
# Hooks
> Hook infrastructure for AIPass. Single engine dispatches all hooks across platforms (Claude, Codex) with per-project config, full logging, and testability. The 13th citizen.
> Hook infrastructure for AIPass. Single engine dispatches all hooks across platforms (Claude, Codex) with per-project config, full logging, and crash isolation. The 13th citizen.
Every hook event flows through one engine. Platform bridges normalize the event format, the engine reads per-project config (.aipass/hooks.json), dispatches matching handlers, and logs everything to prax + JSONL.
Every hook event flows through one engine. Platform bridges normalize the event format, the engine reads per-project config (`.aipass/hooks.json`), dispatches matching handlers, and logs everything to prax + JSONL.
## Start here
@@ -19,7 +19,9 @@ Every hook event flows through one engine. Platform bridges normalize the event
| Command | What it does |
|---|---|
| `drone @hooks` | Show branch structure (auto-discovered modules) |
| `drone @hooks status` | Show hook config for current project |
| `drone @hooks engine` | Show connected handlers |
| `drone @hooks log` | Tail recent hook activity (last 20 JSONL entries) |
| `drone @hooks hooksound` | Show current sound mute status |
| `drone @hooks hooksound off` | Mute all hook sounds |
@@ -27,6 +29,16 @@ Every hook event flows through one engine. Platform bridges normalize the event
| `drone @hooks --help` | Full help reference |
| `drone @hooks --version` | Version info |
## Two-Tier Hook Model
Hooks operate on two tiers:
**Tier 1 — Provider Settings (wiring).** Claude Code's `~/.claude/settings.json` (or project `.claude/settings.json`) defines hook entries that point to the bridge (`claude.py`). These are installed by `setup.sh` / `doctor` — they're pure wiring. Each event type has one bridge entry that fans out to all handlers for that event. Provider settings cannot be changed by branches — only setup tooling manages them.
**Tier 2 — Project Config (control).** Each project's `.aipass/hooks.json` controls which hooks fire for that project. Created by `aipass init`. Edit `enabled` flags to turn hooks on/off per project. Use `drone @hooks status` to view current config.
**Why provider-only wiring?** Claude Code does not fire `PreToolUse`/`PostToolUse` hooks from project-level settings — only from user-level settings (DPLAN-0160 platform limitation). So all hook entries live in provider settings, and per-project control happens through `.aipass/hooks.json`.
## Architecture
```
@@ -37,31 +49,64 @@ src/aipass/hooks/
│ ├── sound.py # Shared sound utilities (speak, play, mute)
│ ├── modules/
│ │ ├── engine.py # Core dispatch — routes events to handlers
│ │ └── hooksound.py # Sound control (drone @hooks hooksound on/off)
│ │ ├── hooksound.py # Sound control (drone @hooks hooksound on/off)
│ │ └── hookstatus.py # Config viewer (drone @hooks status)
│ ├── handlers/
│ │ ├── bridges/ # One per provider (thin normalization)
│ │ │ └── claude.py # Claude Code bridge
│ │ ├── prompt/ # Prompt injection hooks
│ │ ├── security/ # Enforcement hooks (edit gate, git gate)
│ │ ├── lifecycle/ # Session hooks (compact, stop, subagent)
│ │ │ ├── branch_loader.py # Injects aipass_local_prompt.md
│ │ │ ├── global_loader.py # Injects global prompt
│ │ │ └── identity.py # Injects passport identity block
│ │ ├── security/ # Enforcement hooks
│ │ │ ├── edit_gate.py # Blocks unsafe edits (cross-branch, inbox, diagnostics)
│ │ │ ├── git_gate.py # Enforces git access tiers
│ │ │ └── subagent_gate.py # Blocks sub-agent stop until clean
│ │ ├── lifecycle/ # Session management hooks
│ │ │ ├── auto_fix.py # Post-edit diagnostics (ruff, pyright, py_compile)
│ │ │ ├── auto_watchdog.py # Watchdog arming after dispatch
│ │ │ ├── compact.py # Pre-compact memory archival
│ │ │ └── rollover.py # Pre-compact memory rollover
│ │ └── notification/ # Sound/alert hooks
│ └── config/ # hooks.json validation
│ │ ├── announce.py # Announcement tone on notification
│ │ ├── email.py # Inbox check on prompt
│ │ ├── stop_sound.py # Bell on session stop
│ │ └── tool_sound.py # Announces tool name via TTS
│ └── handlers/config/ # Config utilities
│ ├── loader.py # hooks.json discovery + validation
│ └── diagnostics.py # JSONL logging for hook execution
├── logs/
│ └── engine.jsonl # JSONL diagnostics (every hook execution)
├── tests/ # 236 tests
├── tests/ # 253 tests across 19 test files
└── STATUS.local.md
```
## How It Works
1. Provider settings have ONE bridge entry per event type (e.g., `claude.py UserPromptSubmit`)
2. Bridge calls `engine.dispatch(event_type, stdin_data, config)`
3. Engine reads `.aipass/hooks.json` (walks up from CWD)
4. Engine runs matching hooks sequentially, logs each one
1. Provider settings have one bridge entry per event type (e.g., `claude.py UserPromptSubmit`)
2. Bridge normalizes stdin, loads project config via `loader.find_project_config()`
3. Bridge calls `engine.dispatch(event_type, stdin_data, config)`
4. Engine runs matching hooks sequentially, logs each to JSONL
5. First hook returning `{"decision": "block"}` with exit code 2 = bail (block the action)
6. Exit code 2 without JSON = crash (log error, continue to next hook)
7. All hook stdout concatenated and returned to platform
## Dynamic Dispatch
Handlers are called **dynamically at runtime** — the engine uses `importlib.import_module()` + `getattr()` on the dotted handler path from `hooks.json` (e.g., `aipass.hooks.apps.handlers.prompt.identity.handle`). Handlers are never statically imported. This means static analysis tools (including seedgo's dead_code checker) cannot see that they are used. Each handler has been verified wired in `hooks.json` and confirmed firing in `engine.jsonl`.
## Event Types
| Event | Hooks | Description |
|---|---|---|
| UserPromptSubmit | identity, email, branch_loader, global_loader | Prompt injection + inbox check |
| PreToolUse | tool_sound, edit_gate, git_gate | Security gates + sound |
| PostToolUse | auto_fix, auto_watchdog | Diagnostics + watchdog |
| SubagentStop | subagent_gate | Seedgo validation |
| Stop | stop_sound | Achievement bell |
| Notification | announce | Announcement tone |
| PreCompact | compact, rollover | Memory archival + rollover |
## Integration Points
### Depends On
@@ -74,7 +119,7 @@ src/aipass/hooks/
All branches via hook dispatch. Every Claude Code session routes through the engine.
*Last Updated: 2026-05-22*
*Last Updated: 2026-05-28*
---
@@ -21,7 +21,8 @@ Supports two forms:
import sys
from aipass.hooks.apps.modules.engine import dispatch, find_project_config
from aipass.hooks.apps.modules.engine import dispatch
from aipass.hooks.apps.handlers.config.loader import find_project_config
from aipass.prax.apps.modules.logger import system_logger as logger
@@ -25,6 +25,15 @@ def _block(reason: str) -> dict:
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2}
def _get_package_from_cwd(cwd: str) -> str:
"""Derive package name from CWD path (e.g., 'aipass' from src/aipass/hooks)."""
parts = Path(cwd).parts
for i, part in enumerate(parts):
if part == "src" and i + 2 < len(parts):
return parts[i + 1]
return ""
def _find_repo_root(cwd: str) -> Path | None:
"""Walk up from AIPASS_HOME or CWD to find the git repo root."""
for start in (os.environ.get("AIPASS_HOME", ""), cwd):
@@ -39,20 +48,25 @@ def _find_repo_root(cwd: str) -> Path | None:
def _get_cwd_branch(cwd: str, repo_root: Path) -> str | None:
"""Detect which branch directory (src/aipass/<name>) the CWD is in."""
src = repo_root / "src" / "aipass"
"""Detect which branch directory (src/<package>/<name>) the CWD is in."""
package = _get_package_from_cwd(cwd)
if not package:
logger.info("[HOOKS] subagent_gate: CWD %s not inside src/<package>", cwd)
return None
src = repo_root / "src" / package
try:
rel = Path(cwd).resolve().relative_to(src)
return rel.parts[0] if rel.parts else None
except ValueError:
logger.info("[HOOKS] subagent_gate: CWD %s not inside src/aipass", cwd)
logger.info("[HOOKS] subagent_gate: CWD %s not inside %s", cwd, src)
return None
def _get_modified_py_files(cwd: str, repo_root: Path) -> list[str]:
"""Get modified .py files scoped to the CWD branch via drone."""
cwd_branch = _get_cwd_branch(cwd, repo_root)
branch_dir = repo_root / "src" / "aipass" / cwd_branch if cwd_branch else None
package = _get_package_from_cwd(cwd)
branch_dir = repo_root / "src" / package / cwd_branch if (cwd_branch and package) else None
if not branch_dir or not branch_dir.exists():
return []
result = subprocess.run(
@@ -105,7 +119,8 @@ def _run_seedgo_checklist(file_path: str, repo_root: Path) -> list[str]:
def _check_hook_readme_accountability(cwd: str, repo_root: Path) -> str | None:
"""Return advisory if hook files changed without README update."""
cwd_branch = _get_cwd_branch(cwd, repo_root)
branch_dir = repo_root / "src" / "aipass" / cwd_branch if cwd_branch else None
package = _get_package_from_cwd(cwd)
branch_dir = repo_root / "src" / package / cwd_branch if (cwd_branch and package) else None
if not branch_dir or not branch_dir.exists():
return None
result = subprocess.run(
+4 -21
View File
@@ -19,7 +19,6 @@ from pathlib import Path
from aipass.prax.apps.modules.logger import system_logger as logger
from aipass.cli.apps.modules import err_console
from aipass.hooks.apps.handlers.config.loader import find_project_config
from aipass.hooks.apps.handlers.config.diagnostics import log_entry as _log, tail_log
CONSOLE = err_console
@@ -237,33 +236,17 @@ def print_introspection():
def handle_command(command: str, args: list) -> bool:
"""Route engine commands from drone @hooks."""
if not args and command in ("engine", ""):
print_introspection()
return True
if command in ("engine", ""):
if not args:
print_introspection()
return True
if command in ("--help", "-h", "help"):
CONSOLE.print("[bold cyan]engine[/bold cyan] — Hook dispatch engine")
CONSOLE.print()
CONSOLE.print(" drone @hooks status Show hook config for current project")
CONSOLE.print(" drone @hooks log Tail recent hook activity")
return True
if command == "status":
config = find_project_config()
if config is None:
CONSOLE.print("No .aipass/hooks.json found for current project")
else:
enabled = config.get("hooks_enabled", True)
CONSOLE.print(f"Hooks enabled: {enabled}")
for event_type, hooks in config.items():
if event_type.startswith("_") or event_type == "hooks_enabled":
continue
if isinstance(hooks, dict):
active = sum(1 for h in hooks.values() if isinstance(h, dict) and h.get("enabled", True))
total = sum(1 for h in hooks.values() if isinstance(h, dict))
CONSOLE.print(f" {event_type}: {active}/{total} hooks active")
return True
if command == "log":
lines = tail_log(20)
if not lines:
+6 -11
View File
@@ -12,6 +12,7 @@
from aipass.cli.apps.modules import err_console
from aipass.hooks.apps.sound import MUTE_FLAG, is_muted
from aipass.prax.apps.modules.logger import system_logger as logger # noqa: F401
CONSOLE = err_console
@@ -25,7 +26,11 @@ def print_introspection():
def handle_command(command: str, args: list) -> bool:
"""Route hooksound commands from drone @hooks."""
if command == "hooksound":
sub = args[0] if args else None
if not args:
print_introspection()
return True
sub = args[0]
if sub in ("--help", "-h", "help"):
CONSOLE.print("[bold cyan]hooksound[/bold cyan] — Mute/unmute all hook audio")
@@ -46,14 +51,4 @@ def handle_command(command: str, args: list) -> bool:
CONSOLE.print("[green]Hook sounds ACTIVE[/green]")
return True
if sub is None:
if is_muted():
CONSOLE.print("[yellow]Hook sounds: MUTED[/yellow]")
CONSOLE.print(f" Flag: {MUTE_FLAG}")
CONSOLE.print(" Run: drone @hooks hooksound on")
else:
CONSOLE.print("[green]Hook sounds: ACTIVE[/green]")
CONSOLE.print(" Run: drone @hooks hooksound off")
return True
return False
+101
View File
@@ -0,0 +1,101 @@
# =================== AIPass ====================
# Name: hookstatus.py
# Version: 1.0.0
# Description: Hook status — read-only view of per-project hook config
# Branch: hooks
# Layer: apps/modules
# Created: 2026-05-28
# Modified: 2026-05-28
# =============================================
"""Hook status — read-only view of per-project hook configuration via drone @hooks status."""
from aipass.cli.apps.modules import err_console
from aipass.hooks.apps.handlers.config.loader import find_project_config
from aipass.prax.apps.modules.logger import system_logger as logger # noqa: F401
CONSOLE = err_console
EVENT_TYPES = [
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"SubagentStop",
"Stop",
"Notification",
"PreCompact",
]
def print_introspection():
"""Print module structure for drone routing."""
CONSOLE.print("[bold cyan]hookstatus[/bold cyan] — Read-only hook config viewer")
def _render_status(config: dict) -> None:
"""Render the hook configuration to stderr console."""
master = config.get("hooks_enabled", True)
CONSOLE.print()
if master:
CONSOLE.print("[bold green]hooks_enabled: ON[/bold green]")
else:
CONSOLE.print("[bold red]hooks_enabled: OFF — ALL HOOKS DISABLED[/bold red]")
CONSOLE.print()
total = 0
enabled = 0
for event_type in EVENT_TYPES:
group = config.get(event_type)
if not group or not isinstance(group, dict):
continue
CONSOLE.print(f"[bold]{event_type}[/bold]")
for name, hook_def in group.items():
if not isinstance(hook_def, dict):
continue
total += 1
is_enabled = hook_def.get("enabled", False)
if is_enabled:
enabled += 1
glyph = "[green]✓[/green]" if is_enabled else "[dim]✗[/dim]"
matcher = hook_def.get("matcher", "")
matcher_str = f" [dim]matcher: {matcher}[/dim]" if matcher else ""
CONSOLE.print(f" {glyph} {name}{matcher_str}")
CONSOLE.print()
CONSOLE.print(f"[bold]{enabled} enabled / {total} total[/bold]")
def handle_command(command: str, args: list) -> bool:
"""Route status commands from drone @hooks."""
if command != "status":
return False
if not args:
print_introspection()
config = find_project_config()
if config is None:
CONSOLE.print("[yellow]No .aipass/hooks.json found in this directory tree.[/yellow]")
CONSOLE.print("Run: [bold]aipass init[/bold]")
return True
_render_status(config)
return True
sub = args[0]
if sub in ("--help", "-h", "help"):
CONSOLE.print("[bold cyan]hookstatus[/bold cyan] — Read-only hook config viewer")
CONSOLE.print()
CONSOLE.print(" drone @hooks status Show current project hook config")
CONSOLE.print(" drone @hooks status --help Show this help")
CONSOLE.print()
CONSOLE.print("Reads .aipass/hooks.json from the current directory tree.")
CONSOLE.print("Display only — never modifies config.")
return True
return False
+2 -1
View File
@@ -73,7 +73,8 @@ class TestCompactHandler:
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": str(tmp_path)})
with patch.dict("os.environ", {"AIPASS_SESSION_TYPE": ""}):
result = handle({"cwd": str(tmp_path)})
assert "Recovery Protocol" in result["stdout"]
+3 -4
View File
@@ -18,11 +18,11 @@ from unittest.mock import MagicMock, patch
from aipass.hooks.apps.modules.engine import (
dispatch,
find_project_config,
_matches,
_run_hook,
_log,
)
from aipass.hooks.apps.handlers.config.loader import find_project_config
class TestMatches:
@@ -365,7 +365,7 @@ class TestHooksEntryPoint:
def test_status_command_returns_true(self):
from aipass.hooks.apps.hooks import handle_command
with patch("aipass.hooks.apps.modules.engine.find_project_config", return_value=None):
with patch("aipass.hooks.apps.handlers.config.loader.find_project_config", return_value=None):
result = handle_command("status", [])
assert result is True
@@ -551,7 +551,6 @@ class TestConftest:
from aipass.hooks.apps.modules import engine
assert hasattr(engine, "dispatch")
assert hasattr(engine, "find_project_config")
class TestCliRouting:
@@ -568,7 +567,7 @@ class TestCliRouting:
def test_output_capture_status(self, capsys):
from aipass.hooks.apps.hooks import handle_command
with patch("aipass.hooks.apps.modules.engine.find_project_config", return_value=None):
with patch("aipass.hooks.apps.modules.hookstatus.find_project_config", return_value=None):
handle_command("status", [])
captured = capsys.readouterr()
assert "No .aipass/hooks.json" in captured.err
@@ -30,17 +30,6 @@ class TestGlobalLoaderHandler:
assert "AIPass Global" in result["stdout"]
assert "Context here" in result["stdout"]
def test_returns_empty_when_no_aipass_home(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": ""}, clear=False):
result = handle({})
assert result["exit_code"] == 0
assert result["stdout"] == ""
def test_returns_empty_when_file_missing(self, tmp_path, monkeypatch):
from aipass.hooks.apps.handlers.prompt.global_loader import handle
+207
View File
@@ -0,0 +1,207 @@
# =================== AIPass ====================
# Name: test_hookstatus.py
# Version: 1.0.0
# Description: Tests for hookstatus module (drone @hooks status)
# Branch: hooks
# Created: 2026-05-28
# Modified: 2026-05-28
# =============================================
"""Tests for modules/hookstatus.py — read-only hook config viewer."""
from unittest.mock import patch
SAMPLE_CONFIG = {
"hooks_enabled": True,
"UserPromptSubmit": {
"identity_injector": {"enabled": True, "handler": "x.handle", "matcher": ""},
"branch_prompt": {"enabled": False, "handler": "y.handle", "matcher": ""},
},
"PreToolUse": {
"tool_sound": {"enabled": True, "handler": "z.handle", "matcher": "Bash|Edit"},
"edit_gate": {"enabled": True, "handler": "w.handle", "matcher": "Edit|Write"},
},
"Stop": {
"stop_sound": {"enabled": False, "handler": "s.handle", "matcher": ""},
},
}
MASTER_OFF_CONFIG = {
"hooks_enabled": False,
"UserPromptSubmit": {
"identity_injector": {"enabled": True, "handler": "x.handle", "matcher": ""},
},
}
class TestHandleCommand:
"""Command routing tests."""
def test_returns_false_for_unknown_command(self):
"""Non-status commands return False for routing."""
from aipass.hooks.apps.modules.hookstatus import handle_command
assert handle_command("unknown", []) is False
def test_routes_status_command(self):
"""Status command is handled and returns True."""
from aipass.hooks.apps.modules.hookstatus import handle_command
with patch(
"aipass.hooks.apps.modules.hookstatus.find_project_config",
return_value=SAMPLE_CONFIG,
):
assert handle_command("status", []) is True
def test_help_flag(self):
"""--help flag is handled."""
from aipass.hooks.apps.modules.hookstatus import handle_command
assert handle_command("status", ["--help"]) is True
def test_help_short_flag(self):
"""-h flag is handled."""
from aipass.hooks.apps.modules.hookstatus import handle_command
assert handle_command("status", ["-h"]) is True
def test_help_word(self):
"""help subcommand is handled."""
from aipass.hooks.apps.modules.hookstatus import handle_command
assert handle_command("status", ["help"]) is True
class TestConfigPresent:
"""Tests with a valid config file found."""
def test_shows_enabled_and_disabled_hooks(self):
"""Verify mixed enabled/disabled hooks render without error."""
from aipass.hooks.apps.modules.hookstatus import handle_command
with patch(
"aipass.hooks.apps.modules.hookstatus.find_project_config",
return_value=SAMPLE_CONFIG,
):
result = handle_command("status", [])
assert result is True
def test_counts_enabled_total(self):
"""Verify footer shows correct enabled/total counts."""
from aipass.hooks.apps.modules.hookstatus import _render_status
from io import StringIO
from rich.console import Console
buf = StringIO()
test_console = Console(file=buf, force_terminal=False)
with patch("aipass.hooks.apps.modules.hookstatus.CONSOLE", test_console):
_render_status(SAMPLE_CONFIG)
output = buf.getvalue()
assert "3 enabled / 5 total" in output
def test_shows_matcher(self):
"""Verify matcher values appear in output."""
from aipass.hooks.apps.modules.hookstatus import _render_status
from io import StringIO
from rich.console import Console
buf = StringIO()
test_console = Console(file=buf, force_terminal=False)
with patch("aipass.hooks.apps.modules.hookstatus.CONSOLE", test_console):
_render_status(SAMPLE_CONFIG)
output = buf.getvalue()
assert "Bash|Edit" in output
def test_shows_event_group_headers(self):
"""Verify event type section headers appear."""
from aipass.hooks.apps.modules.hookstatus import _render_status
from io import StringIO
from rich.console import Console
buf = StringIO()
test_console = Console(file=buf, force_terminal=False)
with patch("aipass.hooks.apps.modules.hookstatus.CONSOLE", test_console):
_render_status(SAMPLE_CONFIG)
output = buf.getvalue()
assert "UserPromptSubmit" in output
assert "PreToolUse" in output
assert "Stop" in output
class TestConfigAbsent:
"""Tests when no config file is found."""
def test_no_config_shows_message(self):
"""Verify missing config shows instruction to run aipass init."""
from aipass.hooks.apps.modules.hookstatus import handle_command
from io import StringIO
from rich.console import Console
buf = StringIO()
test_console = Console(file=buf, force_terminal=False)
with (
patch(
"aipass.hooks.apps.modules.hookstatus.find_project_config",
return_value=None,
),
patch("aipass.hooks.apps.modules.hookstatus.CONSOLE", test_console),
):
result = handle_command("status", [])
assert result is True
output = buf.getvalue()
assert "No .aipass/hooks.json found" in output
assert "aipass init" in output
class TestMasterSwitchOff:
"""Tests when hooks_enabled is false."""
def test_master_off_shows_warning(self):
"""Verify master switch OFF renders loud warning."""
from aipass.hooks.apps.modules.hookstatus import _render_status
from io import StringIO
from rich.console import Console
buf = StringIO()
test_console = Console(file=buf, force_terminal=False)
with patch("aipass.hooks.apps.modules.hookstatus.CONSOLE", test_console):
_render_status(MASTER_OFF_CONFIG)
output = buf.getvalue()
assert "OFF" in output
assert "ALL HOOKS DISABLED" in output
def test_master_off_still_counts_hooks(self):
"""Verify hook counts still shown even with master OFF."""
from aipass.hooks.apps.modules.hookstatus import _render_status
from io import StringIO
from rich.console import Console
buf = StringIO()
test_console = Console(file=buf, force_terminal=False)
with patch("aipass.hooks.apps.modules.hookstatus.CONSOLE", test_console):
_render_status(MASTER_OFF_CONFIG)
output = buf.getvalue()
assert "1 enabled / 1 total" in output
class TestPrintIntrospection:
"""Module introspection tests."""
def test_prints_without_error(self):
"""Introspection runs without raising."""
from aipass.hooks.apps.modules.hookstatus import print_introspection
print_introspection()
@@ -125,3 +125,65 @@ class TestSubagentGateHandler:
result = handle({"cwd": "/fake/repo/src/aipass/hooks"})
assert result["exit_code"] == 0
assert result["stdout"] == ""
class TestSubagentGateExternalProject:
"""Verify subagent gate works for non-AIPass projects (e.g. src/vera_studio/)."""
def test_get_cwd_branch_external_package(self, tmp_path):
from aipass.hooks.apps.handlers.security.subagent_gate import _get_cwd_branch
# Use tmp_path for OS-native, drive-anchored paths — synthetic POSIX
# strings break on Windows where .resolve() anchors to the current drive.
repo_root = tmp_path / "vera"
cwd = repo_root / "src" / "vera_studio" / "quality"
cwd.mkdir(parents=True)
branch = _get_cwd_branch(str(cwd), repo_root)
assert branch == "quality"
def test_get_cwd_branch_aipass_still_works(self, tmp_path):
from aipass.hooks.apps.handlers.security.subagent_gate import _get_cwd_branch
repo_root = tmp_path / "AIPass"
cwd = repo_root / "src" / "aipass" / "hooks"
cwd.mkdir(parents=True)
branch = _get_cwd_branch(str(cwd), repo_root)
assert branch == "hooks"
def test_get_package_from_cwd_external(self):
from aipass.hooks.apps.handlers.security.subagent_gate import _get_package_from_cwd
assert _get_package_from_cwd("/home/user/Projects/vera/src/vera_studio/quality") == "vera_studio"
assert _get_package_from_cwd("/home/user/Projects/AIPass/src/aipass/hooks") == "aipass"
assert _get_package_from_cwd("/tmp/no-src-here") == ""
@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_block_external_project(self, mock_speak, mock_root, mock_modified, mock_seedgo, mock_readme):
from pathlib import Path
mock_root.return_value = Path("/fake/vera")
mock_modified.return_value = ["/fake/vera/src/vera_studio/quality/apps/bad.py"]
mock_seedgo.return_value = ["Missing docstring"]
result = handle({"cwd": "/fake/vera/src/vera_studio/quality"})
assert result["exit_code"] == 2
parsed = json.loads(result["stdout"])
assert parsed["decision"] == "block"
assert "Missing docstring" in parsed["reason"]
@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):
from pathlib import Path
mock_root.return_value = Path("/fake/vera")
mock_modified.return_value = ["/fake/vera/src/vera_studio/quality/apps/clean.py"]
result = handle({"cwd": "/fake/vera/src/vera_studio/quality"})
assert result["exit_code"] == 0
assert result["stdout"] == ""
@@ -56,7 +56,7 @@ def _read_lock(lock_path: Path) -> Dict:
def build_dispatch_section(branch_path: Path) -> bool:
"""Build dispatch section data and write to dashboard.
Scans all 12 branches for active dispatch locks.
Scans all 13 branches for active dispatch locks.
Args:
branch_path: Path to devpulse branch root.