Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c33cc1f69 | ||
|
|
aa962bdc12 | ||
|
|
fbd90d74b3 | ||
|
|
5fe96307a4 | ||
|
|
e27a50c78d | ||
|
|
f8f102e16f | ||
|
|
97a3276b81 | ||
|
|
35a63b9540 | ||
|
|
574ae79b1a | ||
|
|
a752923ae2 | ||
|
|
0c5d26284c | ||
|
|
b925714589 | ||
|
|
91a9eeb233 |
+2
-1
@@ -4,4 +4,5 @@
|
||||
!.gitignore
|
||||
!project_CLAUDE.md
|
||||
!project_global_prompt.md
|
||||
#Do not add other exceptions here without careful consideration. Developer permissions0ns needed.
|
||||
!project_hooks.json
|
||||
#Do not add other exceptions here without careful consideration. Developer permissions0ns needed.
|
||||
@@ -57,7 +57,7 @@ src/aipass/{name}/
|
||||
└── README.md
|
||||
```
|
||||
|
||||
12 core branches: aipass, drone, seedgo, prax, cli, flow, ai_mail, api, trigger, spawn, memory, devpulse.
|
||||
13 core branches: aipass, drone, seedgo, prax, cli, flow, ai_mail, api, trigger, spawn, memory, devpulse, hooks.
|
||||
|
||||
# Commands
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,3 +32,28 @@ jobs:
|
||||
name: dist
|
||||
path: dist/
|
||||
- uses: pypa/gh-action-pypi-publish@release/v1
|
||||
|
||||
github-release:
|
||||
needs: publish
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
- name: Extract latest CHANGELOG section
|
||||
run: |
|
||||
# Grab the topmost "## [...]" block from CHANGELOG.md as release notes.
|
||||
awk '/^## \[/{c++} c==1' CHANGELOG.md | sed '/^---$/d' > release_notes.md
|
||||
echo "Release notes:" && cat release_notes.md
|
||||
- name: Create GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release create "${GITHUB_REF_NAME}" \
|
||||
--title "${GITHUB_REF_NAME}" \
|
||||
--notes-file release_notes.md \
|
||||
dist/*
|
||||
|
||||
@@ -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
@@ -8,24 +8,56 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format
|
||||
|
||||
---
|
||||
|
||||
## [2026.W22] - 2026-06-01
|
||||
## [2026.W22] - 2026-05-30
|
||||
|
||||
### Added
|
||||
|
||||
- **`drone @hooks status`** — read-only viewer for a project's hook config:
|
||||
master switch, every hook's enabled state per event group, matchers, and an
|
||||
enabled/total summary. Resolves the project's `.aipass/hooks.json` by walking
|
||||
up from CWD. (DPLAN-0190 Phase B)
|
||||
- **Hooks activate in every project** — `aipass init` now writes
|
||||
`.aipass/hooks.json`, so new projects fire the hook engine out of the box
|
||||
(previously: no config shipped, 0 hooks fired). `aipass init update`
|
||||
union-merges the template, preserving any per-hook on/off choices the user
|
||||
made. `aipass doctor` now checks for the config's presence. Dead hook-script
|
||||
shipping (`_ship_hooks`) removed. (DPLAN-0190 Phase A)
|
||||
- **README logo** — centered logo image replaces plain `# AIPass` header.
|
||||
New `assets/logo.png` added to the repo.
|
||||
- **OpenSSF Scorecard** — `.github/workflows/scorecard.yml` runs the official
|
||||
OSSF Scorecard action on push to `main` and weekly. Publishes a public security
|
||||
health score at scorecard.dev with a README badge. Actions pinned by SHA.
|
||||
- **GitHub Releases** — `publish.yml` now cuts a GitHub Release on each `v*` tag,
|
||||
with notes pulled from the top CHANGELOG section and the built dist attached.
|
||||
PyPI publish + GitHub Release now fire from the same tag.
|
||||
- **Registry descriptions** — all 13 branches now have one-liner descriptions
|
||||
in `AIPASS_REGISTRY.json`. `drone systems` shows what each agent does
|
||||
instead of blank lines. Closes [#607](https://github.com/AIOSAI/AIPass/issues/607).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Edit gate now project-aware** — cross-branch write protection and daemon
|
||||
confinement no longer hardcode `src/aipass/`. The package name is derived
|
||||
dynamically from CWD, so any `src/<package>/<branch>/` project gets the
|
||||
same security. 4 new tests for external projects. Addresses
|
||||
[#605](https://github.com/AIOSAI/AIPass/issues/605).
|
||||
- **Security gates fully project-aware** — both the edit gate *and* the
|
||||
subagent stop gate now derive the package name dynamically from CWD instead
|
||||
of hardcoding `src/aipass/`. Cross-branch write protection and branch
|
||||
detection work for any `src/<package>/<branch>/` project; previously the
|
||||
subagent gate silently no-opped outside AIPass. 9 new external-project tests.
|
||||
Closes [#605](https://github.com/AIOSAI/AIPass/issues/605).
|
||||
- **Hooks branch promoted to service** — registry profile changed from
|
||||
"AIPass Workshop" to "library" so it appears in `drone systems` alongside
|
||||
the other 12 services.
|
||||
- **Hooks branch hardened to 100% seedgo** — the @hooks citizen took full
|
||||
ownership of its branch: every handler verified wired + firing, README
|
||||
rewritten (two-tier provider/project model, dynamic-dispatch design, event
|
||||
table), 2 stale tests resolved (253 pass). Dead-code/unused-function flags
|
||||
documented as architectural bypasses — the 15 handlers are invoked
|
||||
dynamically via `importlib` from `hooks.json` paths, never statically
|
||||
imported. (DPLAN-0191)
|
||||
|
||||
### Release
|
||||
|
||||
- **Version 2.5.0** published to PyPI. Trusted publishing via GitHub Actions
|
||||
(`publish.yml` triggers on `v*` tags — no manual twine upload needed). The
|
||||
same tag now also cuts a GitHub Release with these notes attached.
|
||||
|
||||
### Removed
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ User: user
|
||||
|
||||
On any greeting, silently run this sequence — no narration, no announcing steps. Just do it and respond with the status.
|
||||
|
||||
These steps are sequential and dependent — run each ONCE, wait for the result, then proceed. Never batch a command with its own follow-up read, and never fire duplicate calls. If output looks blank, wait — don't retry.
|
||||
|
||||
- Read: `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md`, `STATUS.local.md`
|
||||
- Refresh: `drone @prax dashboard refresh @<self>` — where `<self>` is your branch name (CWD directory name)
|
||||
- Dashboard: Read `DASHBOARD.local.json` — act on what needs attention (new mail → check inbox, active plans → note them). This is your single status glance.
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
</p>
|
||||
<p align="center"><strong>Persistent Agent Workspace</strong></p>
|
||||
<p align="center"><em>AI agents that remember, collaborate, and never start from zero.</em></p>
|
||||
<p align="center">
|
||||
<a href="https://scorecard.dev/viewer/?uri=github.com/AIOSAI/AIPass"><img src="https://api.scorecard.dev/projects/github.com/AIOSAI/AIPass/badge" alt="OpenSSF Scorecard" /></a>
|
||||
</p>
|
||||
|
||||

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