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 !.gitignore
!project_CLAUDE.md !project_CLAUDE.md
!project_global_prompt.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 └── 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 # 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 name: dist
path: dist/ path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1 - 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 ### 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 - **Registry descriptions** — all 13 branches now have one-liner descriptions
in `AIPASS_REGISTRY.json`. `drone systems` shows what each agent does in `AIPASS_REGISTRY.json`. `drone systems` shows what each agent does
instead of blank lines. Closes [#607](https://github.com/AIOSAI/AIPass/issues/607). instead of blank lines. Closes [#607](https://github.com/AIOSAI/AIPass/issues/607).
### Changed ### Changed
- **Edit gate now project-aware** — cross-branch write protection and daemon - **Security gates fully project-aware** — both the edit gate *and* the
confinement no longer hardcode `src/aipass/`. The package name is derived subagent stop gate now derive the package name dynamically from CWD instead
dynamically from CWD, so any `src/<package>/<branch>/` project gets the of hardcoding `src/aipass/`. Cross-branch write protection and branch
same security. 4 new tests for external projects. Addresses detection work for any `src/<package>/<branch>/` project; previously the
[#605](https://github.com/AIOSAI/AIPass/issues/605). 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 - **Hooks branch promoted to service** — registry profile changed from
"AIPass Workshop" to "library" so it appears in `drone systems` alongside "AIPass Workshop" to "library" so it appears in `drone systems` alongside
the other 12 services. 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 ### 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. 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` - 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) - 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. - 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>
<p align="center"><strong>Persistent Agent Workspace</strong></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"><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) ![demo](assets/demo.gif)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "aipass" 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" 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" readme = "README.md"
license = "MIT" 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 "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 "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 "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 # External branches
# NOTE: backup, daemon removed S82/S87. commons, skills moved to external repos. # 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 --- # --- Seed branch config files from .example defaults ---
# Some branches need a config file that's gitignored (contains local state). # 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 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
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__) 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: def _sanitize_name(raw: str) -> str:
"""Sanitize a project name for use in filenames. """Sanitize a project name for use in filenames.
@@ -201,6 +147,52 @@ def _merge_settings(existing: dict, generated: dict) -> dict:
return merged 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: def _claude_settings(aipass_home: str | None = None) -> str:
"""Generate .claude/settings.json — env and permissions only. """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(rm -rf *)",
"Bash(git push --force*)", "Bash(git push --force*)",
"Bash(git reset --hard*)", "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") global_prompt_path.write_text(_resolve_global_prompt(name, aipass_home, global_prompt_path), encoding="utf-8")
created.append(str(global_prompt_path)) 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 # 3-5. CLAUDE.md, AGENTS.md — project templates or AIPass source
for md_name in ("CLAUDE.md", "AGENTS.md"): for md_name in ("CLAUDE.md", "AGENTS.md"):
dest = target / md_name 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") prep_path.write_text(sc.prep_md(), encoding="utf-8")
created.append(str(prep_path)) 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) # 10. src/<project>/ package structure (pip-installable from day one)
package_name = raw_name.lower().replace("-", "_").replace(" ", "_") package_name = raw_name.lower().replace("-", "_").replace(" ", "_")
src_dir = target / "src" src_dir = target / "src"
@@ -531,6 +529,34 @@ def update_project(target: Path) -> dict:
else: else:
already_current.append(str(settings_path)) 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 # CLAUDE.md, AGENTS.md — sync from project templates or AIPass source
for md_name in ("CLAUDE.md", "AGENTS.md"): for md_name in ("CLAUDE.md", "AGENTS.md"):
dest = target / md_name dest = target / md_name
@@ -557,12 +583,6 @@ def update_project(target: Path) -> dict:
else: else:
already_current.append(str(prep_path)) 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 --- # --- User-owned files: always skip ---
for skip_name in ( for skip_name in (
str(registry_path), str(registry_path),
+21 -27
View File
@@ -6,11 +6,7 @@
# Modified: 2026-04-16 # Modified: 2026-04-16
# ============================================= # =============================================
""" """aipass doctor — system health aggregation."""
aipass doctor — system health aggregation
Run: aipass doctor [--verbose] [--fix] [--fix --json]
"""
from __future__ import annotations from __future__ import annotations
@@ -60,10 +56,6 @@ from aipass.aipass.apps.handlers.ui.progress import (
make_doctor_progress, make_doctor_progress,
) )
# =============================================================================
# TYPES
# =============================================================================
_BRANCH_ROOT = Path(__file__).resolve().parents[2] _BRANCH_ROOT = Path(__file__).resolve().parents[2]
@@ -76,9 +68,7 @@ class CheckResult(NamedTuple):
remediation: str remediation: str
# ============================================================================= # --- Identity helpers ---
# IDENTITY HELPERS
# =============================================================================
def _find_registry() -> Path | None: def _find_registry() -> Path | None:
@@ -97,9 +87,7 @@ def _find_registry() -> Path | None:
return None return None
# ============================================================================= # --- Check groups ---
# CHECK GROUPS
# =============================================================================
def _check_system() -> List[CheckResult]: def _check_system() -> List[CheckResult]:
@@ -201,6 +189,20 @@ def _check_identity() -> List[CheckResult]:
else: else:
results.append(CheckResult("registry valid", GLYPH_FAIL, "missing 'branches' key", "Re-run 'aipass init'")) 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 readable
passport = _BRANCH_ROOT / ".trinity" / "passport.json" passport = _BRANCH_ROOT / ".trinity" / "passport.json"
if passport.exists(): if passport.exists():
@@ -478,9 +480,7 @@ def _check_community() -> List[CheckResult]:
return results return results
# ============================================================================= # --- Structure check group ---
# STRUCTURE CHECK GROUP
# =============================================================================
def _check_structure() -> List[CheckResult]: def _check_structure() -> List[CheckResult]:
@@ -558,9 +558,7 @@ def _check_structure() -> List[CheckResult]:
return results return results
# ============================================================================= # --- Main doctor run ---
# MAIN DOCTOR RUN
# =============================================================================
def run_doctor(verbose: bool = False, interactive: bool = False, fix: bool = False) -> int: 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 return error_count
# ============================================================================= # --- Output formatting ---
# OUTPUT FORMATTING
# =============================================================================
def print_introspection() -> None: def print_introspection() -> None:
@@ -652,9 +648,7 @@ def print_help() -> None:
console.print() console.print()
# ============================================================================= # --- Command handler ---
# COMMAND HANDLER
# =============================================================================
def handle_command(command: str, args: list[str]) -> bool: 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 import scaffold_content as sc
from aipass.aipass.apps.handlers.init.bootstrap import ( from aipass.aipass.apps.handlers.init.bootstrap import (
_merge_hooks_json,
_sanitize_name, _sanitize_name,
init_project, init_project,
update_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"]] created_basenames = [Path(f).name for f in result["created_files"]]
for f in expected_files: for f in expected_files:
assert f.name in created_basenames or f.exists(), f"Expected {f.name} in created_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): 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 target.is_dir()
assert result["project_name"] == "NESTED" 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): 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) result = update_project(target)
assert len(result["updated_files"]) == 0 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): 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 / ".aipass" / "aipass_global_prompt.md").exists()
assert (target / ".claude" / "settings.json").exists() assert (target / ".claude" / "settings.json").exists()
# Managed files in deleted dirs re-written (global_prompt, settings, prep) # Managed files in deleted dirs re-written (global_prompt, hooks.json, settings, prep)
assert len(result["updated_files"]) == 3 assert len(result["updated_files"]) == 4
assert len(result["already_current"]) >= 2 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() assert not memo_path.exists()
def test_init_project_ships_hooks(tmp_path): def test_init_project_creates_hooks_json(tmp_path):
"""init_project copies enforcement + injector hooks to target .claude/hooks/.""" """init_project creates .aipass/hooks.json from project_hooks.json template."""
target = tmp_path / "proj" target = tmp_path / "proj"
target.mkdir() target.mkdir()
@@ -654,16 +655,46 @@ def test_init_project_ships_hooks(tmp_path):
if result["aipass_home"] is None: if result["aipass_home"] is None:
pytest.skip("AIPASS_HOME not detectable in this environment") pytest.skip("AIPASS_HOME not detectable in this environment")
hooks_dir = target / ".claude" / "hooks" hooks_json = target / ".aipass" / "hooks.json"
# Post DPLAN-0184: hooks are native handlers in src/aipass/hooks/, assert hooks_json.exists(), ".aipass/hooks.json should be created"
# no longer shipped as script copies. Directory may or may not exist. data = json.loads(hooks_json.read_text(encoding="utf-8"))
if hooks_dir.exists(): assert data.get("hooks_enabled") is True
shipped = [f.name for f in hooks_dir.iterdir()] assert "UserPromptSubmit" in data
assert len(shipped) == 0, f"No hook scripts should be shipped post-migration: {shipped}"
def test_init_project_hooks_not_shipped_without_aipass_home(tmp_path, monkeypatch): def test_init_project_hooks_json_matches_template(tmp_path):
"""When AIPASS_HOME is not detectable, hooks are not shipped.""" """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 = tmp_path / "proj"
target.mkdir() 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") init_project(target, project_name="nohooks")
hooks_dir = target / ".claude" / "hooks" assert not (target / ".aipass" / "hooks.json").exists()
assert not hooks_dir.exists() or len(list(hooks_dir.iterdir())) == 0
def test_init_project_no_audio_hooks_shipped(tmp_path): def test_init_project_no_hook_scripts_shipped(tmp_path):
"""Audio hooks (notification_sound, tool_use_sound, stop_sound) are never shipped.""" """No hook scripts are shipped to .claude/hooks/ (engine runs from $AIPASS_HOME)."""
target = tmp_path / "proj" target = tmp_path / "proj"
target.mkdir() target.mkdir()
init_project(target, project_name="noaudio") init_project(target, project_name="noscripts")
hooks_dir = target / ".claude" / "hooks" hooks_dir = target / ".claude" / "hooks"
if hooks_dir.exists(): if hooks_dir.exists():
shipped = [f.name for f in hooks_dir.iterdir()] shipped = [f.name for f in hooks_dir.iterdir()]
assert "notification_sound.py" not in shipped assert len(shipped) == 0, f"No hook scripts should be shipped: {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"
def test_init_project_settings_has_no_hook_events(tmp_path): 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 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 # scaffold_content — global_prompt_md tests
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+44 -5
View File
@@ -441,10 +441,8 @@ class TestProviderManifest:
manifest = tmp_path / ".claude" / "provider_manifest.json" manifest = tmp_path / ".claude" / "provider_manifest.json"
manifest.parent.mkdir(parents=True) manifest.parent.mkdir(parents=True)
cmd_a = "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Stop" cmd_a = "$AIPASS_HOME/bin/hook-bridge Stop"
cmd_b = ( cmd_b = "$AIPASS_HOME/bin/hook-bridge Notification"
"$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Notification"
)
manifest.write_text( manifest.write_text(
json.dumps( json.dumps(
{ {
@@ -487,7 +485,7 @@ class TestProviderManifest:
manifest = tmp_path / ".claude" / "provider_manifest.json" manifest = tmp_path / ".claude" / "provider_manifest.json"
manifest.parent.mkdir(parents=True) 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( manifest.write_text(
json.dumps( json.dumps(
{ {
@@ -573,3 +571,44 @@ class TestProviderManifest:
result = _find_manifest() result = _find_manifest()
assert result is not None assert result is not None
assert result == manifest 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 *.log
*.tmp *.tmp
*.swp *.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 | | Command routing | @drone | @branch resolution, subprocess |
| Memory, vectors | @memory | ChromaDB, search, archival | | Memory, vectors | @memory | ChromaDB, search, archival |
| User onboarding, init | @aipass | Concierge, aipass init, doctor, scanner | | 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 ## Git — Dev Branch, Drone Only, You Are Gatekeeper
@@ -82,9 +83,9 @@ drone @flow list open # active plans
drone systems # all branches 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 ## Working Habits
+185 -389
View File
@@ -1,398 +1,194 @@
{ {
"metadata": { "metadata": {
"version": "1.1.0", "version": "2.0.0",
"created": "2026-05-18", "created": "2026-05-18",
"updated": "2026-05-21", "updated": "2026-05-28",
"description": "Standards bypass configuration for this branch" "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": [ "bypass": [
{ {"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/modules/engine.py", {"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."},
"standard": "json_structure", {"file": "apps/handlers/bridges/claude.py", "standard": "unused_function", "reason": "main() called as subprocess entry point from provider settings — never statically imported."},
"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": "architecture", "reason": "Bridge importing engine module is its architectural purpose."},
"file": "apps/handlers/bridges/claude.py", {"file": "apps/handlers/bridges/claude.py", "standard": "imports", "reason": "Bridge imports engine module by design — sole purpose."},
"standard": "handlers",
"reason": "Bridges import engine module by design — that is their entire 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/bridges/claude.py",
"standard": "json_structure", {"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."},
"reason": "Thin entry point, no JSON operations to log" {"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/bridges/claude.py", {"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."},
"standard": "dead_code", {"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)."},
"reason": "Bridge called externally by provider settings subprocess — no internal import" {"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/bridges/claude.py", {"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)."},
"standard": "architecture", {"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."},
"reason": "Bridge importing engine module is its architectural purpose"
}, {"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/bridges/claude.py", {"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."},
"standard": "imports",
"reason": "Bridge imports engine module by design — sole purpose" {"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/hooks.py", {"file": "apps/handlers/security/subagent_gate.py", "standard": "open_encoding", "reason": "NamedTemporaryFile creates binary wav for Piper TTS — encoding not applicable to binary audio."},
"standard": "unused_function",
"reason": "print_introspection() called by drone's discovery system, not internal code" {"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/config/loader.py",
"standard": "json_structure", {"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."},
"reason": "Config loader does $AIPASS_HOME variable expansion before JSON parse — json_handler does not support this" {"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/config/diagnostics.py", {"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)."},
"standard": "json_structure", {"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."},
"reason": "JSONL append-only diagnostic log — different pattern from branch json_handler storage" {"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/notification/tool_sound.py", {"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."},
"standard": "json_structure", {"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."},
"reason": "Sound handler — no JSON operations, plays WAV files"
}, {"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": "tests/conftest.py", {"file": "apps/handlers/notification/announce.py", "standard": "json_structure", "reason": "Sound handler — no JSON operations, plays WAV files."},
"standard": "architecture",
"reason": "Test fixtures live in tests/, not in the 3-layer apps structure" {"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": "tests/test_tool_sound.py",
"standard": "architecture", {"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."},
"reason": "Test files live in tests/, not in the 3-layer apps structure" {"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": "tests/test_tool_sound.py", {"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."},
"standard": "documentation", {"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)."},
"reason": "Test methods use descriptive names as documentation per pytest convention" {"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": "tests/test_tool_sound.py", {"file": "apps/handlers/config/diagnostics.py", "standard": "json_structure", "reason": "JSONL append-only diagnostic log — different pattern from branch json_handler storage."},
"standard": "encapsulation",
"reason": "Tests import handlers directly to test implementation details" {"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": "tests/test_tool_sound.py", {"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."},
"standard": "meta", {"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'."},
"reason": "Test files do not need Version/Modified metadata headers"
}, {"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": "tests/test_engine.py", {"file": "apps/hooks.py", "standard": "unused_function", "reason": "print_introspection() called by drone's discovery system, not internal code."},
"standard": "architecture",
"reason": "Test files live in tests/, not in the 3-layer apps structure" {"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/test_engine.py",
"standard": "encapsulation", {"file": "tests/conftest.py", "standard": "architecture", "reason": "Test fixtures live in tests/, not in the 3-layer apps structure."},
"reason": "Tests import handlers directly to test implementation details" {"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", {"file": "tests/test_engine.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."},
"standard": "documentation", {"file": "tests/test_engine.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."},
"reason": "Test methods use descriptive names as documentation per pytest convention" {"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", {"file": "tests/test_engine.py", "standard": "exception_contracts", "reason": "Hooks has no json_handler create_default/save_invalid/invalid_mode patterns."},
"standard": "help_text",
"reason": "Test data contains command references as part of test fixtures, not user-facing help" {"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_engine.py", {"file": "tests/test_tool_sound.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."},
"standard": "json_handler",
"reason": "Hooks branch does not use json_handler — has its own JSONL logging" {"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_engine.py", {"file": "tests/test_stop_sound.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."},
"standard": "exception_contracts",
"reason": "Hooks has no json_handler create_default/save_invalid/invalid_mode patterns" {"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": "apps/handlers/notification/announce.py", {"file": "tests/test_announce.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."},
"standard": "json_structure",
"reason": "Sound handler — no JSON operations, plays WAV files" {"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": "apps/handlers/notification/stop_sound.py", {"file": "tests/test_email.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."},
"standard": "json_structure",
"reason": "Sound handler — no JSON operations, plays WAV files" {"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_stop_sound.py", {"file": "tests/test_subagent_gate.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."},
"standard": "architecture",
"reason": "Test files live in tests/, not in the 3-layer apps structure" {"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_stop_sound.py", {"file": "tests/test_auto_fix.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."},
"standard": "documentation", {"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."},
"reason": "Test methods use descriptive names as documentation per pytest convention" {"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_stop_sound.py", {"file": "tests/test_identity.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
"standard": "encapsulation", {"file": "tests/test_identity.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."},
"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_stop_sound.py", {"file": "tests/test_branch_loader.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
"standard": "meta", {"file": "tests/test_branch_loader.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."},
"reason": "Test files do not need Version/Modified metadata headers" {"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_announce.py", {"file": "tests/test_global_loader.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
"standard": "architecture", {"file": "tests/test_global_loader.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."},
"reason": "Test files live in tests/, not in the 3-layer apps structure" {"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_announce.py", {"file": "tests/test_compact.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
"standard": "documentation", {"file": "tests/test_compact.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."},
"reason": "Test methods use descriptive names as documentation per pytest convention" {"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_announce.py", {"file": "tests/test_rollover.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
"standard": "encapsulation", {"file": "tests/test_rollover.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."},
"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_announce.py", {"file": "tests/test_hookstatus.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
"standard": "meta", {"file": "tests/test_hookstatus.py", "standard": "encapsulation", "reason": "Tests import modules directly to test implementation details."},
"reason": "Test files do not need Version/Modified metadata headers" {"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": "apps/handlers/notification/email.py", {"file": "tests/test_hooksound.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
"standard": "json_structure", {"file": "tests/test_hooksound.py", "standard": "encapsulation", "reason": "Tests import modules directly to test implementation details."},
"reason": "Uses stdlib json.loads for inbox parsing — no JSON file ops needing json_handler" {"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": "apps/handlers/lifecycle/auto_watchdog.py", {"file": "tests/test_auto_watchdog.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
"standard": "json_structure", {"file": "tests/test_auto_watchdog.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."},
"reason": "Uses stdlib json.dumps to produce additionalContext output — no JSON file ops needing json_handler" {"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": "apps/handlers/lifecycle/auto_fix.py", {"file": "tests/test_edit_gate.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
"standard": "json_structure", {"file": "tests/test_edit_gate.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."},
"reason": "Diagnostics handler uses stdlib json for hook protocol responses and state file — no JSON file ops needing json_handler" {"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": "apps/handlers/security/edit_gate.py", {"file": "tests/test_git_gate.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
"standard": "json_structure", {"file": "tests/test_git_gate.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."},
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler" {"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": "apps/handlers/security/git_gate.py", {"file": "tests/test_sound.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
"standard": "json_structure", {"file": "tests/test_sound.py", "standard": "encapsulation", "reason": "Tests import sound module directly to test implementation details."},
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler" {"file": "tests/test_sound.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."}
},
{
"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"
}
], ],
"notes": { "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 # 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 ## Start here
@@ -19,7 +19,9 @@ Every hook event flows through one engine. Platform bridges normalize the event
| Command | What it does | | Command | What it does |
|---|---| |---|---|
| `drone @hooks` | Show branch structure (auto-discovered modules) |
| `drone @hooks status` | Show hook config for current project | | `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 log` | Tail recent hook activity (last 20 JSONL entries) |
| `drone @hooks hooksound` | Show current sound mute status | | `drone @hooks hooksound` | Show current sound mute status |
| `drone @hooks hooksound off` | Mute all hook sounds | | `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 --help` | Full help reference |
| `drone @hooks --version` | Version info | | `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 ## Architecture
``` ```
@@ -37,31 +49,64 @@ src/aipass/hooks/
│ ├── sound.py # Shared sound utilities (speak, play, mute) │ ├── sound.py # Shared sound utilities (speak, play, mute)
│ ├── modules/ │ ├── modules/
│ │ ├── engine.py # Core dispatch — routes events to handlers │ │ ├── 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/ │ ├── handlers/
│ │ ├── bridges/ # One per provider (thin normalization) │ │ ├── bridges/ # One per provider (thin normalization)
│ │ │ └── claude.py # Claude Code bridge │ │ │ └── claude.py # Claude Code bridge
│ │ ├── prompt/ # Prompt injection hooks │ │ ├── prompt/ # Prompt injection hooks
│ │ ├── security/ # Enforcement hooks (edit gate, git gate) │ │ │ ├── branch_loader.py # Injects aipass_local_prompt.md
│ │ ├── lifecycle/ # Session hooks (compact, stop, subagent) │ │ │ ├── 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 │ │ └── 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/ ├── logs/
│ └── engine.jsonl # JSONL diagnostics (every hook execution) │ └── engine.jsonl # JSONL diagnostics (every hook execution)
├── tests/ # 236 tests ├── tests/ # 253 tests across 19 test files
└── STATUS.local.md └── STATUS.local.md
``` ```
## How It Works ## How It Works
1. Provider settings have ONE bridge entry per event type (e.g., `claude.py UserPromptSubmit`) 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)` 2. Bridge normalizes stdin, loads project config via `loader.find_project_config()`
3. Engine reads `.aipass/hooks.json` (walks up from CWD) 3. Bridge calls `engine.dispatch(event_type, stdin_data, config)`
4. Engine runs matching hooks sequentially, logs each one 4. Engine runs matching hooks sequentially, logs each to JSONL
5. First hook returning `{"decision": "block"}` with exit code 2 = bail (block the action) 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) 6. Exit code 2 without JSON = crash (log error, continue to next hook)
7. All hook stdout concatenated and returned to platform 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 ## Integration Points
### Depends On ### Depends On
@@ -74,7 +119,7 @@ src/aipass/hooks/
All branches via hook dispatch. Every Claude Code session routes through the engine. 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 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 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} 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: def _find_repo_root(cwd: str) -> Path | None:
"""Walk up from AIPASS_HOME or CWD to find the git repo root.""" """Walk up from AIPASS_HOME or CWD to find the git repo root."""
for start in (os.environ.get("AIPASS_HOME", ""), cwd): 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: def _get_cwd_branch(cwd: str, repo_root: Path) -> str | None:
"""Detect which branch directory (src/aipass/<name>) the CWD is in.""" """Detect which branch directory (src/<package>/<name>) the CWD is in."""
src = repo_root / "src" / "aipass" 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: try:
rel = Path(cwd).resolve().relative_to(src) rel = Path(cwd).resolve().relative_to(src)
return rel.parts[0] if rel.parts else None return rel.parts[0] if rel.parts else None
except ValueError: 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 return None
def _get_modified_py_files(cwd: str, repo_root: Path) -> list[str]: def _get_modified_py_files(cwd: str, repo_root: Path) -> list[str]:
"""Get modified .py files scoped to the CWD branch via drone.""" """Get modified .py files scoped to the CWD branch via drone."""
cwd_branch = _get_cwd_branch(cwd, repo_root) 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(): if not branch_dir or not branch_dir.exists():
return [] return []
result = subprocess.run( 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: def _check_hook_readme_accountability(cwd: str, repo_root: Path) -> str | None:
"""Return advisory if hook files changed without README update.""" """Return advisory if hook files changed without README update."""
cwd_branch = _get_cwd_branch(cwd, repo_root) 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(): if not branch_dir or not branch_dir.exists():
return None return None
result = subprocess.run( 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.prax.apps.modules.logger import system_logger as logger
from aipass.cli.apps.modules import err_console 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 from aipass.hooks.apps.handlers.config.diagnostics import log_entry as _log, tail_log
CONSOLE = err_console CONSOLE = err_console
@@ -237,33 +236,17 @@ def print_introspection():
def handle_command(command: str, args: list) -> bool: def handle_command(command: str, args: list) -> bool:
"""Route engine commands from drone @hooks.""" """Route engine commands from drone @hooks."""
if not args and command in ("engine", ""): if command in ("engine", ""):
print_introspection() if not args:
return True print_introspection()
return True
if command in ("--help", "-h", "help"): if command in ("--help", "-h", "help"):
CONSOLE.print("[bold cyan]engine[/bold cyan] — Hook dispatch engine") CONSOLE.print("[bold cyan]engine[/bold cyan] — Hook dispatch engine")
CONSOLE.print() CONSOLE.print()
CONSOLE.print(" drone @hooks status Show hook config for current project")
CONSOLE.print(" drone @hooks log Tail recent hook activity") CONSOLE.print(" drone @hooks log Tail recent hook activity")
return True 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": if command == "log":
lines = tail_log(20) lines = tail_log(20)
if not lines: if not lines:
+6 -11
View File
@@ -12,6 +12,7 @@
from aipass.cli.apps.modules import err_console from aipass.cli.apps.modules import err_console
from aipass.hooks.apps.sound import MUTE_FLAG, is_muted 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 CONSOLE = err_console
@@ -25,7 +26,11 @@ def print_introspection():
def handle_command(command: str, args: list) -> bool: def handle_command(command: str, args: list) -> bool:
"""Route hooksound commands from drone @hooks.""" """Route hooksound commands from drone @hooks."""
if command == "hooksound": 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"): if sub in ("--help", "-h", "help"):
CONSOLE.print("[bold cyan]hooksound[/bold cyan] — Mute/unmute all hook audio") 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]") CONSOLE.print("[green]Hook sounds ACTIVE[/green]")
return True 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 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.speak"):
with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None): 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"] 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 ( from aipass.hooks.apps.modules.engine import (
dispatch, dispatch,
find_project_config,
_matches, _matches,
_run_hook, _run_hook,
_log, _log,
) )
from aipass.hooks.apps.handlers.config.loader import find_project_config
class TestMatches: class TestMatches:
@@ -365,7 +365,7 @@ class TestHooksEntryPoint:
def test_status_command_returns_true(self): def test_status_command_returns_true(self):
from aipass.hooks.apps.hooks import handle_command 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", []) result = handle_command("status", [])
assert result is True assert result is True
@@ -551,7 +551,6 @@ class TestConftest:
from aipass.hooks.apps.modules import engine from aipass.hooks.apps.modules import engine
assert hasattr(engine, "dispatch") assert hasattr(engine, "dispatch")
assert hasattr(engine, "find_project_config")
class TestCliRouting: class TestCliRouting:
@@ -568,7 +567,7 @@ class TestCliRouting:
def test_output_capture_status(self, capsys): def test_output_capture_status(self, capsys):
from aipass.hooks.apps.hooks import handle_command 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", []) handle_command("status", [])
captured = capsys.readouterr() captured = capsys.readouterr()
assert "No .aipass/hooks.json" in captured.err assert "No .aipass/hooks.json" in captured.err
@@ -30,17 +30,6 @@ class TestGlobalLoaderHandler:
assert "AIPass Global" in result["stdout"] assert "AIPass Global" in result["stdout"]
assert "Context here" 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): def test_returns_empty_when_file_missing(self, tmp_path, monkeypatch):
from aipass.hooks.apps.handlers.prompt.global_loader import handle 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"}) result = handle({"cwd": "/fake/repo/src/aipass/hooks"})
assert result["exit_code"] == 0 assert result["exit_code"] == 0
assert result["stdout"] == "" 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: def build_dispatch_section(branch_path: Path) -> bool:
"""Build dispatch section data and write to dashboard. """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: Args:
branch_path: Path to devpulse branch root. branch_path: Path to devpulse branch root.