feat(system): feat(aipass): DPLAN-0164 Phases 4-5 — remove init from CLI, move tests, fix routing assertions

Co-Authored-By: @devpulse <devpulse@aipass>
This commit is contained in:
AIOSAI
2026-05-04 21:37:32 -07:00
co-authored by @devpulse
parent ee2716e63f
commit 9281efeacc
43 changed files with 3099 additions and 2326 deletions
@@ -0,0 +1,109 @@
<!-- Source: /home/patrick/Projects/AIPass/src/aipass/aipass/.aipass/aipass_global_prompt.md -->
# AIPASS — Project Context
<!-- Injected every turn via hook. -->
## What is AIPass
AIPass is a multi-agent framework. Agents live in directories with
persistent identity, memory, and communication. All AIPass infrastructure
is available from any project via the `drone` command.
## Terminology
- **Project** — this directory. Contains a registry and agents.
- **Agent** — a citizen with identity (`.trinity/`), memory, mailbox,
and code (`apps/`).
- **Registry** — `AIPASS_REGISTRY.json` tracks all agents.
## Setup: if drone commands fail
If `drone` cannot find the AIPass registry, set the env var:
```bash
export AIPASS_HOME=/path/to/AIPass # path to AIPass installation
```
Add to your shell profile (`~/.bashrc` or `~/.zshrc`) to make it permanent.
## Commands
### Agent Lifecycle
```
aipass init agent <name> # Create a new agent in src/<name>/
drone @spawn create <name> # Create agent (alternative)
drone @spawn list # List registered agents
```
### Standards
```
drone @seedgo audit <project> # Run full standards audit
drone @seedgo checklist <file> # Check a single file
```
### Dispatch — Send Task + Wake an Agent (DEFAULT)
```
drone @ai_mail dispatch @<agent> "Subject" "Body" # Send + wake (default)
drone @ai_mail dispatch @<agent> "Subject" "Body" --fresh # Send + wake fresh session
drone @ai_mail dispatch wake @<agent> # Wake without sending
drone @ai_mail dispatch wake --fresh @<agent> # Wake fresh
drone @ai_mail email @<agent> "Subject" "Body" # FYI only (no wake)
```
Use `dispatch` by default. Use `email` only when you don't need the agent to act now.
### Communication (ai_mail)
```
drone @ai_mail inbox # Check your mailbox
drone @ai_mail view <id> # Read a message
drone @ai_mail close <id> # Mark message read
```
### Feedback
```
drone @devpulse feedback send "Subject" "Body" # Send feedback (cross-project)
```
### Plans (flow)
```
drone @flow create . "Subject" dplan # Create DPLAN (design/thinking)
drone @flow create . "Subject" master # Create FPLAN master (execution)
drone @flow create . "Subject" aplan # Create APLAN (agent-level task)
drone @flow list open # List active plans
drone @flow list # List all plans
drone @flow close <id> # Close a plan
drone @flow info <id> # View plan details
```
**DPLAN** = Dev Plan. Thinking, brainstorming, architecture decisions. Use before building.
**FPLAN** = Flow Plan. Building and executing. Use when the plan is clear and work is underway.
### Memory
```
drone @memory archive # Archive memories to vector store
drone @memory search <query> # Search archived memories
```
### Git Workflow
```
drone @git pr 'description' # Create a pull request
drone @git status # Git status (branch-scoped)
drone @git sync # Sync with main
drone @git lock / unlock # Lock/unlock the repo
```
### Infrastructure
```
drone systems # List all available infrastructure
drone --help # Full drone command reference
```
## Patterns
- **Communication** — agents communicate via `.ai_mail.local/`.
- **Standards** — run `drone @seedgo audit` to check compliance.
- **Identity** — agents have `.trinity/passport.json`. Projects use the registry.
- **Memory** — update `.trinity/local.json` at session end. Memory is presence.
## Maintenance
- **Upgrade scaffold**: `drone @cli aipass init update` refreshes managed project files (hooks, prompts, settings) to latest templates.
- **Entry point**: each agent's `apps/{name}.py` auto-configures `sys.path` and `AIPASS_BRANCH_NAME` env var. If prax logs to `unknown_branch/`, check that these are set.
- **Standalone projects** use `src/{name}/` layout (not `src/aipass/{name}/`). Module discovery adapts automatically.
@@ -0,0 +1,23 @@
# Memory Update
Purpose: Update branch memory files after completing work this session.
## Execution
1. Read `.trinity/passport.json` first — re-absorb your identity, role, and principles before writing memories
2. Review what was done this session (context, recent changes, key decisions)
3. Update each file below as needed
4. Confirm completion — list files updated
## Memory Roles
Each memory file plays a distinct role. Update based on what actually changed this session.
- **`.trinity/passport.json`** — IDENTITY. Who you are: role, capabilities, principles. Only update if identity genuinely evolved this session. Don't touch it just to touch it.
- **`.trinity/local.json`** — YOUR MEMORY. Session history and key_learnings. Add a session entry for significant work. Add key_learnings for facts you'd need next time. Trim oldest sessions if over 20.
- **`.trinity/observations.json`** — YOUR MEMORY OF THE USER. Collaboration insights, preferences, friction points, flow states. Skip entirely if nothing new about the user this session.
- **`STATUS.local.md`** — PUBLIC STATUS BEACON. Current work, known issues, todos, notepad. Auto-synced to central STATUS.md on PR events — this is how other branches see you. Keep Current Work accurate and drop quick notes in the Notepad section.
## If Relevant
- **README.md** — Does it reflect current state? Update if stale.
@@ -0,0 +1,57 @@
# Session Wrap-Up
Purpose: Button up everything at the end of a session — or before a /compact. Memories, plans, git — all tidy. Works for both closing out a chat and preparing for compaction.
**Workflow:** `/prep` → review output → close chat or `/compact`
## Execution
1. Read `.trinity/passport.json` first — re-absorb your identity before writing anything
2. Do ALL of the following, then confirm what was updated
## 1. Memories
Each memory file plays a distinct role. Update based on what actually changed this session.
- **`.trinity/passport.json`** — IDENTITY. Who you are: role, capabilities, principles. Only update if identity genuinely evolved this session.
- **`.trinity/local.json`** — YOUR MEMORY. Add/update session entry with a summary of work done. Add key_learnings for anything learned. Trim oldest sessions if over 20.
- **`.trinity/observations.json`** — YOUR MEMORY OF THE USER. Collaboration insights, preferences, friction points. Skip if nothing new about the user this session.
- **`STATUS.local.md`** — PUBLIC STATUS BEACON. Current work, known issues, todos, notepad. Auto-synced to central STATUS.md on PR events — this is how other branches see you. Keep Current Work accurate.
## 2. Active Plans
- Check any DPLANs or FPLANs referenced in this session
- Update their execution logs, status, decision logs with current state
- If a plan was completed, note it (but don't close — the user does that)
## 3. Git State
- Run `git status` — report uncommitted changes
- If there's a logical commit waiting, suggest it (don't commit without asking)
- Note the current branch and any open PRs
## 4. Inbox
- Run `drone @ai_mail inbox 2>/dev/null` — report any unread emails
- Close any that were already processed but not formally closed
## 5. Loose Ends
- Flag anything in-flight: running background agents, dispatched branches waiting for replies, pending decisions
- If anything can't survive compaction (e.g., agent IDs needed for resume), write it to STATUS.local.md Notepad
## Confirm
List everything updated. Format:
```
Prep complete:
- local.json: [what was added]
- observations.json: [updated / skipped]
- STATUS.local.md: [updated / skipped]
- Plans: [which ones updated]
- Git: [branch, uncommitted count, suggestion]
- Inbox: [count, action taken]
- Loose ends: [any flagged]
Ready to close out or /compact.
```
@@ -0,0 +1,393 @@
#!/usr/bin/env python3
"""
PostToolUse Auto-fix Hook — Detects errors and surfaces them for fixing.
Two-hook system:
PostToolUse (this file) → runs pyright + ruff on edited file, saves errors to state
PreToolUse (pre_edit_gate.py) → blocks edits to OTHER files until errors fixed
Key behaviors:
- Runs py_compile (syntax), ruff lint+format, pyright (type errors) on edited file
- Runs seedgo checklist for AIPass standards
- Saves ruff lint AND pyright errors to state file for PreToolUse gate (hard block)
- Surfaces ALL errors in additionalContext so Claude sees them
Version: 5.2.0
CHANGELOG:
- v5.2.0 (2026-04-20): Save ruff lint errors to state file for hard-block enforcement.
Pre-edit gate now blocks on F401/lint just like type errors.
- v5.1.0 (2026-04-19): Added ruff format --check to surface format drift.
- v5.0.0 (2026-03-17): Replaced mcp__ide__getDiagnostics with direct pyright.
Added state file for PreToolUse gate integration.
Single-file pyright (not whole project).
- v4.3.0 (2026-03-17): Added seedgo checklist integration
- v4.0.0 (2025-11-27): Complete rewrite - actual validation, silent operation
"""
import json
import sys
import subprocess
from pathlib import Path
EDIT_TOOLS = ["Edit", "Write", "MultiEdit", "NotebookEdit"]
LAST_FILE_PATH = Path(__file__).parent / ".last_diagnostics_file"
STATE_FILE = Path(__file__).parent / ".diagnostics_state.json"
SKIP_EXTENSIONS = {".md", ".txt", ".log", ".csv", ".html"}
# AIPass-specific Python patterns to check
PYTHON_PATTERNS = {
"bad_optional": {
"pattern": ": str = None",
"message": "Optional param should use 'str | None = None' pattern"
},
"logger_debug": {
"pattern": "logger.debug(",
"message": "Use logger.info for SystemLogger (logger.debug not supported)"
},
"return_error_msg": {
"pattern": "return error_msg",
"message": "Return None for error states, not error_msg string"
},
"open_no_encoding": {
"pattern": "open(",
"requires_missing": "encoding=",
"message": "open() without encoding='utf-8'"
},
"log_not_log_operation": {
"pattern": ".log(",
"message": "Use log_operation() with success/error params, not .log()"
},
"dict_none_no_check": {
"pattern": "Dict | None",
"message": "Dict | None return: Add None check before using (if result is None: return)"
}
}
# JSON-specific patterns for emoji corruption
JSON_CORRUPTION_CHARS = ['\ufffd', '\x00']
def run_python_checks(file_path: str) -> list[str]:
"""Run actual Python validation - returns list of errors."""
errors = []
# 1. Syntax check with py_compile
try:
result = subprocess.run(
[sys.executable, "-m", "py_compile", file_path],
capture_output=True,
text=True,
timeout=5
)
if result.returncode != 0:
errors.append(f"SYNTAX: {result.stderr.strip()}")
except Exception:
pass
# 2. Ruff check (if available) - fast linter
try:
result = subprocess.run(
["ruff", "check", "--select=E,F,W", "--output-format=text", file_path],
capture_output=True,
text=True,
timeout=10
)
if result.stdout.strip():
for line in result.stdout.strip().split("\n")[:5]:
errors.append(f"LINT: {line}")
except FileNotFoundError:
pass
except Exception:
pass
# 3. Ruff format check — detect format drift
try:
result = subprocess.run(
["ruff", "format", "--check", file_path],
capture_output=True,
text=True,
timeout=10
)
if result.returncode != 0:
errors.append(f"FORMAT: {Path(file_path).name} needs ruff format (run: ruff format {Path(file_path).name})")
except FileNotFoundError:
pass
except Exception:
pass
# 4. AIPass-specific pattern checks
try:
content = Path(file_path).read_text(encoding="utf-8")
lines = content.split("\n")
for check in PYTHON_PATTERNS.values():
pattern = check["pattern"]
message = check["message"]
requires_missing = check.get("requires_missing")
if requires_missing:
if pattern in content and requires_missing not in content:
errors.append(f"PATTERN: {message}")
continue
for line in lines:
stripped = line.strip()
if stripped.startswith(("#", '"', "'")):
continue
if f'"{pattern}' in line or f"'{pattern}" in line:
continue
if pattern in line:
errors.append(f"PATTERN: {message}")
break
except Exception:
pass
return errors
def run_ruff_lint_structured(file_path: str) -> list[dict]:
"""Run ruff check and return structured violations for the state file.
Returns list of {line, message} dicts — same format as pyright errors.
Only non-empty when ruff finds real violations (not format drift).
"""
if '/.claude/hooks/' in file_path:
return []
try:
result = subprocess.run(
["ruff", "check", "--select=E,F,W", "--output-format=json", file_path],
capture_output=True, text=True, timeout=10
)
if not result.stdout.strip():
return []
violations = json.loads(result.stdout)
if not isinstance(violations, list):
return []
errors = []
for v in violations[:10]:
line = v.get("location", {}).get("row", 0)
code = v.get("code", "?")
message = v.get("message", "unknown")[:100]
errors.append({"line": line, "message": f"{code}: {message}"})
return errors
except (FileNotFoundError, json.JSONDecodeError, subprocess.TimeoutExpired, Exception):
return []
def run_pyright_check(file_path: str) -> list[dict]:
"""Run pyright on a single file. Returns list of error dicts."""
# Skip hook files - they don't follow project standards
if '/.claude/hooks/' in file_path:
return []
try:
result = subprocess.run(
[sys.executable, "-m", "pyright", "--outputjson", file_path],
capture_output=True,
text=True,
timeout=15
)
try:
data = json.loads(result.stdout)
except (json.JSONDecodeError, ValueError):
return []
errors = []
for diag in data.get("generalDiagnostics", []):
severity = diag.get("severity", "")
if severity == "error":
line = diag.get("range", {}).get("start", {}).get("line", 0)
message = diag.get("message", "Unknown error")
errors.append({
"line": line,
"message": message[:100]
})
return errors[:10] # Max 10 errors
except FileNotFoundError:
return [] # pyright not installed
except subprocess.TimeoutExpired:
return [] # Timeout — don't block
except Exception:
return []
def save_diagnostics_state(file_path: str, errors: list[dict]):
"""Save type errors to state file for PreToolUse gate."""
try:
if errors:
state = {
"file": str(Path(file_path).resolve()),
"errors": errors
}
STATE_FILE.write_text(json.dumps(state), encoding="utf-8")
else:
# No errors — clear the state
if STATE_FILE.exists():
STATE_FILE.unlink()
except Exception:
pass
def run_json_checks(file_path: str) -> list[str]:
"""Run actual JSON validation - returns list of errors."""
errors = []
try:
content = Path(file_path).read_text(encoding="utf-8")
for char in JSON_CORRUPTION_CHARS:
if char in content:
errors.append(f"EMOJI CORRUPTION: Found corrupted character '{repr(char)}'")
break
try:
data = json.loads(content)
if isinstance(data, dict):
for key in ['allowed_emojis', 'emojis', 'emoji_list']:
if key in data and isinstance(data[key], list):
for item in data[key]:
if isinstance(item, str) and len(item) == 1:
if ord(item) < 128 and item not in '\u2713\u2717':
errors.append(f"EMOJI CORRUPTION: Suspicious char '{item}' in {key}")
break
except json.JSONDecodeError as e:
errors.append(f"JSON SYNTAX: {e.msg} at line {e.lineno}")
except Exception as e:
errors.append(f"READ ERROR: {e!s}")
return errors
def run_seedgo_checklist(file_path: str) -> list[str]:
"""Run seedgo standards checklist — returns violations only."""
if '/.claude/hooks/' in file_path:
return []
try:
result = subprocess.run(
["drone", "@seedgo", "checklist", file_path],
capture_output=True,
text=True,
timeout=15,
cwd=str(Path.home() / "Projects" / "AIPass")
)
if result.returncode != 0:
return []
violations = []
for line in result.stdout.split("\n"):
line = line.strip()
if line.startswith("\u2717"):
violation = line[1:].strip()
if violation:
violations.append(violation)
return violations[:5]
except FileNotFoundError:
return []
except Exception:
return []
def should_skip_file(file_path: str) -> bool:
"""Check if file should be skipped."""
if not file_path:
return True
ext = Path(file_path).suffix.lower()
return ext in SKIP_EXTENSIONS
def is_same_file_as_last(file_path: str) -> bool:
"""Smart batching DISABLED — always recheck.
Previously skipped rechecks on the same file, but this caused
errors introduced on second edit to be missed (state file didn't
exist from first clean edit, so skip triggered). The 1.7s pyright
cost per edit is acceptable for correctness.
"""
return False
def main():
"""Main hook entry point."""
try:
input_data = json.load(sys.stdin)
tool_name = input_data.get("tool_name", "")
tool_input = input_data.get("tool_input", {})
file_path = tool_input.get("file_path", "")
if tool_name not in EDIT_TOOLS:
return
if should_skip_file(file_path):
return
if is_same_file_as_last(file_path):
return
# Collect all errors
errors = []
file_type = ""
if file_path.endswith(".py"):
file_type = "Python"
errors = run_python_checks(file_path)
# Seedgo standards checklist
seedgo_violations = run_seedgo_checklist(file_path)
for v in seedgo_violations:
errors.append(f"SEEDGO: {v}")
# Pyright type errors (single file)
type_errors = run_pyright_check(file_path)
for te in type_errors:
errors.append(f"TYPE: L{te['line']}: {te['message']}")
# Save ruff lint + type errors to state file for PreToolUse gate (hard block)
ruff_lint_errors = run_ruff_lint_structured(file_path)
save_diagnostics_state(file_path, ruff_lint_errors + type_errors)
elif file_path.endswith(".json"):
file_type = "JSON"
errors = run_json_checks(file_path)
else:
return
# Build output
if errors:
error_text = "\n".join(f" - {e}" for e in errors)
context = f"""[AUTO-FIX] {len(errors)} error(s) in {Path(file_path).name}:
{error_text}
Fix these errors in {Path(file_path).name} now. Do not skip or defer."""
output = {
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": context
},
"systemMessage": f"[AUTO-FIX] {len(errors)} error(s) — fix before continuing"
}
print(json.dumps(output))
else:
output = {
"systemMessage": "[diagnostics] ok"
}
print(json.dumps(output))
except Exception:
pass # Silent fail
if __name__ == "__main__":
main()
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""
Branch Prompt Loader — AIPass Public Repo
Injects branch-specific prompts based on CWD. When working in a branch
directory, loads .aipass/aipass_local_prompt.md and outputs it so the
AI sees branch-specific context.
Version: 1.0.0
"""
from pathlib import Path
def find_branch_root() -> Path | None:
"""
Find the branch root directory.
Looks for .trinity/ or .aipass/ as branch indicators.
Stops at the repo root (has pyproject.toml or .git).
"""
cwd = Path.cwd()
search_path = cwd
while search_path.parent != search_path:
# Branch indicators: has .trinity/ (memory files) or apps/ (code)
has_trinity = (search_path / ".trinity").is_dir()
has_apps = (search_path / "apps").is_dir()
if has_trinity or has_apps:
return search_path
# Stop at repo root
if (search_path / "pyproject.toml").exists() or (search_path / ".git").is_dir():
return None
search_path = search_path.parent
return None
def main():
branch_root = find_branch_root()
if branch_root:
prompt_file = branch_root / ".aipass" / "aipass_local_prompt.md"
if prompt_file.exists():
content = prompt_file.read_text().strip()
branch_name = branch_root.name.upper()
print(f"\n# Branch Context: {branch_name}\n<!-- Source: {prompt_file} -->\n{content}")
if __name__ == "__main__":
main()
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""
Email Notification Hook - Notifies of new emails on prompt submit.
Checks the current branch's inbox for unread emails and displays
a notification if any exist.
Version: 1.0.0
"""
import json
from pathlib import Path
def find_repo_root() -> Path | None:
"""Find the repo root (contains pyproject.toml or .git)."""
search = Path.cwd()
while search.parent != search:
if (search / "pyproject.toml").exists() or (search / ".git").is_dir():
return search
search = search.parent
return None
def find_branch_root() -> Path | None:
"""Find the branch root directory by walking up from CWD."""
cwd = Path.cwd()
repo_root = find_repo_root()
if not repo_root:
return None
search_path = cwd
for _ in range(10):
has_trinity = (search_path / ".trinity").is_dir()
has_id = list(search_path.glob("*.id.json"))
has_apps = (search_path / "apps").is_dir()
has_mail = (search_path / ".ai_mail.local").is_dir() or (search_path / "ai_mail.local").is_dir()
if (has_trinity or has_id or has_apps or has_mail) and search_path != repo_root:
return search_path
if search_path == repo_root:
break
parent = search_path.parent
if parent == search_path:
break
search_path = parent
return None
def count_new_emails(branch_root: Path) -> int:
"""Count new (unread) emails in the branch's inbox."""
# Check both patterns: .ai_mail.local (canonical) and ai_mail.local (legacy)
inbox_path = branch_root / ".ai_mail.local" / "inbox.json"
if not inbox_path.exists():
inbox_path = branch_root / "ai_mail.local" / "inbox.json"
if not inbox_path.exists():
return 0
try:
with open(inbox_path, "r", encoding="utf-8") as f:
data = json.load(f)
# Handle both formats: {"messages": [...]} and bare [...]
messages = data if isinstance(data, list) else data.get("messages", [])
count = 0
for msg in messages:
if msg.get("status") == "new":
count += 1
elif msg.get("status") is None and not msg.get("read", False):
count += 1
return count
except (json.JSONDecodeError, OSError):
return 0
def main():
branch_root = find_branch_root()
if not branch_root:
return
new_count = count_new_emails(branch_root)
if new_count > 0:
plural = "s" if new_count != 1 else ""
print(f"You have {new_count} new email{plural} - check with: drone @ai_mail inbox | then: drone @ai_mail view <id> | close with: drone @ai_mail close <id>")
if __name__ == "__main__":
main()
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""
Identity Injector - Injects branch identity on every prompt.
Reads from [BRANCH].id.json and outputs core identity fields.
Finds the branch root by walking up from CWD looking for apps/ or *.id.json.
Version: 1.0.0
"""
import json
from pathlib import Path
def find_repo_root() -> Path | None:
"""Find the repo root (contains pyproject.toml or .git)."""
search = Path.cwd()
while search.parent != search:
if (search / "pyproject.toml").exists() or (search / ".git").is_dir():
return search
search = search.parent
return None
def find_branch_root() -> Path | None:
"""Find the branch root directory by walking up from CWD."""
cwd = Path.cwd()
repo_root = find_repo_root()
if not repo_root:
return None
search_path = cwd
while search_path >= repo_root:
has_trinity = (search_path / ".trinity").is_dir()
has_id = list(search_path.glob("*.id.json"))
if has_trinity or has_id:
return search_path
if search_path == repo_root:
break
search_path = search_path.parent
return None
def find_id_file(branch_root: Path) -> Path | None:
"""Find the identity file for a branch (.trinity/passport.json or *.id.json)."""
# AIPass pattern: .trinity/passport.json
passport = branch_root / ".trinity" / "passport.json"
if passport.exists():
return passport
# Dev-Pass fallback: *.id.json
id_files = list(branch_root.glob("*.id.json"))
if id_files:
return id_files[0]
return None
def format_identity(data: dict) -> str:
"""Format branch_info + identity for injection."""
lines = []
# Try branch_info first (enriched passports), fall back to identity block (setup.sh passports)
branch = data.get("branch_info", {})
identity = data.get("identity", {})
name = branch.get("branch_name") or identity.get("name", "UNKNOWN")
lines.append(f"# {name} Identity")
lines.append(f"Path: {branch.get('path', 'unknown')}")
lines.append(f"Email: {branch.get('email', 'unknown')}")
identity = data.get("identity", {})
if identity.get("role"):
lines.append(f"Role: {identity['role']}")
traits = identity.get("traits") or data.get("traits")
if traits:
if isinstance(traits, list):
lines.append("Traits: " + " | ".join(traits))
else:
lines.append(f"Traits: {traits}")
if identity.get("purpose"):
lines.append(f"Purpose: {identity['purpose']}")
what_i_do = identity.get("what_i_do", [])
if what_i_do:
lines.append("Do: " + " | ".join(what_i_do[:4]))
what_i_dont_do = identity.get("what_i_dont_do", [])
if what_i_dont_do:
lines.append("Don't: " + " | ".join(what_i_dont_do[:3]))
principles = data.get("principles", [])
if principles:
lines.append("Principles: " + " * ".join(principles))
return "\n".join(lines)
def main():
branch_root = find_branch_root()
if not branch_root:
return
id_file = find_id_file(branch_root)
if not id_file or not id_file.exists():
return
try:
data = json.loads(id_file.read_text(encoding="utf-8"))
output = format_identity(data)
if output:
print(f"\n{output}")
except (json.JSONDecodeError, KeyError):
pass
if __name__ == "__main__":
main()
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""
Pre-Compact Hook - Inject live state for post-compact recovery.
Reads STATUS.local.md, last session from local.json, and git branch
to give the model real context after compaction — not generic advice.
Version: 3.0.0
"""
import json
import subprocess
import sys
from pathlib import Path
def _find_branch_dir():
"""Find the current branch directory from CWD."""
cwd = Path.cwd()
# Check if we're in a branch dir or subdirectory of one
# Pattern: .../src/aipass/{branch}/...
parts = cwd.parts
for i, part in enumerate(parts):
if part == "aipass" and i > 0 and parts[i - 1] == "src":
branch_dir = Path(*parts[: i + 2])
if branch_dir.is_dir():
return branch_dir
# Check if CWD itself has .trinity/
if (cwd / ".trinity").is_dir():
return cwd
return None
def _read_status_local(branch_dir):
"""Read STATUS.local.md if it exists."""
for name in ["STATUS.local.md", "dev.local.md"]:
path = branch_dir / name
if path.is_file():
try:
return path.read_text(encoding="utf-8")[:3000]
except Exception:
pass
return None
def _read_last_session(branch_dir):
"""Read the most recent session and key_learnings from local.json."""
local_path = branch_dir / ".trinity" / "local.json"
if not local_path.is_file():
return None
try:
data = json.loads(local_path.read_text(encoding="utf-8"))
result = []
# Last session
sessions = data.get("sessions", [])
if sessions:
last = sessions[0]
result.append(
f"Last session (#{last.get('session_number', '?')}, "
f"{last.get('date', '?')}): {last.get('summary', 'no summary')}"
)
# Key learnings (just the keys, not full values — breadcrumbs)
learnings = data.get("key_learnings", {})
if learnings:
keys = list(learnings.keys())[-10:] # last 10
result.append(f"Key learnings available: {', '.join(keys)}")
return "\n".join(result) if result else None
except Exception:
return None
def _get_git_info():
"""Get current git branch and short status."""
try:
branch = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
text=True,
timeout=5,
)
status = subprocess.run(
["git", "diff", "--stat", "--cached", "HEAD"],
capture_output=True,
text=True,
timeout=5,
)
dirty = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True,
text=True,
timeout=5,
)
result = []
if branch.returncode == 0:
result.append(f"Git branch: {branch.stdout.strip()}")
if dirty.returncode == 0 and dirty.stdout.strip():
lines = dirty.stdout.strip().split("\n")
result.append(f"Uncommitted changes: {len(lines)} files")
return "\n".join(result) if result else None
except Exception:
return None
def _get_branch_name(branch_dir):
"""Extract branch name from directory."""
return branch_dir.name if branch_dir else "unknown"
def main():
"""Main hook entry point."""
try:
json.load(sys.stdin)
branch_dir = _find_branch_dir()
branch_name = _get_branch_name(branch_dir)
sections = []
sections.append(f"""POST-COMPACT RECOVERY — @{branch_name}
Context just compacted. Below is your live state. Use it to continue seamlessly.""")
# Git info
git_info = _get_git_info()
if git_info:
sections.append(f"## Git\n{git_info}")
# Last session from local.json
if branch_dir:
session_info = _read_last_session(branch_dir)
if session_info:
sections.append(f"## Last Session\n{session_info}")
# STATUS.local.md — the main context
if branch_dir:
status = _read_status_local(branch_dir)
if status:
sections.append(f"## STATUS.local.md\n{status}")
# Recovery instructions (lean)
sections.append("""## Recovery Protocol
- Continue where the summary left off — don't restart or ask generic questions
- .trinity/local.json has full session history and key_learnings — read it if you need more context
- STATUS.local.md has current work, known issues, and todos
- Save memories proactively — compaction just proved you need to
- Match the conversation tone from before compaction""")
print("\n\n".join(sections), file=sys.stdout)
print("Pre-compact: live state injected", file=sys.stderr)
except Exception as e:
# Fail silently — never block compaction
print(f"Pre-compact hook error: {e}", file=sys.stderr)
sys.exit(0)
if __name__ == "__main__":
main()
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""
PreToolUse Gate — Blocks unsafe edits at the hook layer.
Rules (checked in order):
1. Inbox lock — any write targeting *.ai_mail.local/inbox.json is BLOCKED.
Use `drone @ai_mail email` instead.
2. Cross-branch — writes to src/aipass/X/** from a CWD inside src/aipass/Y/**
are BLOCKED unless the calling branch is in TRUSTED_CROSS_WRITERS.
3. State-file — edits to OTHER .py files while the current branch has unresolved
type errors are BLOCKED. (original v1.2.0 logic)
Track E additions: rules 1 + 2 (DPLAN-0139).
Version: 1.3.0
"""
import json
import os
import sys
from pathlib import Path
STATE_FILE = Path(__file__).parent / ".diagnostics_state.json"
EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
# Single source of truth lives in permissions.py — inline here as fallback
# so the hook works even when aipass package is not on sys.path.
TRUSTED_CROSS_WRITERS: tuple[str, ...] = ("devpulse", "seedgo", "spawn")
def _get_branch(file_path: str) -> str:
"""Extract AIPass branch name from a file path (src/aipass/{branch}/ pattern)."""
parts = Path(file_path).parts
for i, part in enumerate(parts):
if part == "aipass" and i > 0 and parts[i - 1] == "src" and i + 1 < len(parts):
return parts[i + 1]
return ""
def _block(reason: str) -> None:
print(json.dumps({"decision": "block", "reason": reason}))
sys.exit(2)
def main():
try:
input_data = json.load(sys.stdin)
tool_name = input_data.get("tool_name", "")
tool_input = input_data.get("tool_input", {})
file_path = tool_input.get("file_path", "")
if tool_name not in EDIT_TOOLS:
return
if not file_path:
return
# ------------------------------------------------------------------
# Rule 1: Inbox lock — block all writes to *.ai_mail.local/inbox.json
# ------------------------------------------------------------------
fp = Path(file_path)
if fp.name == "inbox.json" and ".ai_mail.local" in fp.parts:
_block('Direct writes to inbox.json are blocked.\nUse: drone @ai_mail email @<branch> "Subject" "Body"')
# ------------------------------------------------------------------
# Rule 1.5: Dispatched-agent path confinement (DPLAN-0155 M3)
# Daemon-spawned agents can only write inside their own branch dir.
# Breaks the prompt-injection amplifier chain — even if injected,
# a dispatched agent cannot write to other agents' inboxes or code.
# ------------------------------------------------------------------
cwd = input_data.get("cwd", "") or os.getcwd()
cwd_branch = _get_branch(cwd)
session_type = os.environ.get("AIPASS_SESSION_TYPE", "interactive")
if session_type == "daemon" and cwd_branch:
target_branch = _get_branch(str(fp.resolve()) if not fp.is_absolute() else str(fp))
if target_branch and target_branch != cwd_branch:
_block(
f"Dispatched agent confined to own branch: '{cwd_branch}' "
f"cannot write to '{target_branch}' in daemon mode."
)
repo_root = None
for parent in Path(cwd).parents:
if (parent / ".git").exists():
repo_root = parent
break
if repo_root and not target_branch:
allowed_prefix = str(repo_root / "src" / "aipass" / cwd_branch)
resolved = str(fp.resolve()) if not fp.is_absolute() else str(fp)
if not resolved.startswith(allowed_prefix):
_block(f"Dispatched agent restricted to {allowed_prefix}. Cannot write to: {file_path}")
# ------------------------------------------------------------------
# Rule 2: Cross-branch write enforcement
# ------------------------------------------------------------------
target_branch = _get_branch(str(fp.resolve()) if not fp.is_absolute() else str(fp))
if cwd_branch and target_branch and cwd_branch != target_branch:
if cwd_branch not in TRUSTED_CROSS_WRITERS:
_block(
f"Cross-branch write blocked: '{cwd_branch}' cannot write to '{target_branch}'.\n"
f"Trusted cross-writers: {', '.join(TRUSTED_CROSS_WRITERS)}"
)
# ------------------------------------------------------------------
# Rule 3: State-file (original v1.2.0) — .py files only
# ------------------------------------------------------------------
if not file_path.endswith(".py"):
return
if not STATE_FILE.exists():
return
try:
state = json.loads(STATE_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, IOError):
return
errored_file = state.get("file", "")
errors = state.get("errors", [])
if not errors:
return
try:
current = str(Path(file_path).resolve())
errored = str(Path(errored_file).resolve())
except (OSError, ValueError):
return
if current == errored:
return
current_branch = _get_branch(current)
errored_branch = _get_branch(errored)
if not errored_branch:
return
if current_branch and errored_branch and current_branch != errored_branch:
return
error_summary = "\n".join(f" L{e['line']}: {e['message']}" for e in errors[:5])
_block(f"Fix {len(errors)} error(s) in {Path(errored_file).name} before editing other files:\n{error_summary}")
except Exception:
pass # Silent fail → allow
if __name__ == "__main__":
main()
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""
SubagentStop Gate — Checks files modified by subagents before allowing them to finish.
Runs seedgo checklist + basic validation on any .py files the subagent touched.
If violations found, blocks the stop and tells the subagent to fix them.
Version: 1.0.0
"""
import json
import os
import sys
import subprocess
from pathlib import Path
def _find_repo_root() -> Path | None:
"""Walk up from CWD or AIPASS_HOME to find the git repo root."""
for start in (os.environ.get("AIPASS_HOME", ""), os.getcwd()):
p = Path(start)
while p != p.parent:
if (p / ".git").exists():
return p
p = p.parent
return None
AIPASS_ROOT = _find_repo_root()
def get_modified_py_files() -> list[str]:
"""Get Python files modified in the working tree (unstaged + staged)."""
if AIPASS_ROOT is None:
return []
try:
result = subprocess.run(
["git", "diff", "--name-only", "HEAD"], capture_output=True, text=True, timeout=5, cwd=str(AIPASS_ROOT)
)
files = []
for line in result.stdout.strip().split("\n"):
line = line.strip()
if line.endswith(".py") and not line.startswith(".claude/"):
full = AIPASS_ROOT / line
if full.exists():
files.append(str(full))
return files
except Exception:
return []
def run_seedgo_checklist(file_path: str) -> list[str]:
"""Run seedgo checklist on a single file."""
if AIPASS_ROOT is None:
return []
if "/.claude/" in file_path:
return []
try:
result = subprocess.run(
["drone", "@seedgo", "checklist", file_path],
capture_output=True,
text=True,
timeout=15,
cwd=str(AIPASS_ROOT),
)
if result.returncode != 0:
return []
violations = []
for line in result.stdout.split("\n"):
line = line.strip()
if line.startswith("\u2717"):
v = line[1:].strip()
if v:
violations.append(v)
return violations[:5]
except Exception:
return []
def main():
try:
input_data = json.load(sys.stdin)
modified = get_modified_py_files()
if not modified:
return # Nothing to check
all_violations = {}
for f in modified:
vs = run_seedgo_checklist(f)
if vs:
name = Path(f).name
all_violations[name] = vs
if not all_violations:
return # All clear
# Build the block reason
lines = ["Standards violations found in files you modified:\n"]
for fname, vs in all_violations.items():
lines.append(f" {fname}:")
for v in vs:
lines.append(f" - {v}")
lines.append("\nFix these violations before finishing.")
output = {"decision": "block", "reason": "\n".join(lines)}
print(json.dumps(output))
except Exception:
pass # Silent fail — don't block on errors
if __name__ == "__main__":
main()
+98
View File
@@ -0,0 +1,98 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "python3 .claude/hooks/auto_fix_diagnostics.py"
}
]
}
],
"PreToolUse": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "python3 .claude/hooks/pre_edit_gate.py"
}
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "python3 .claude/hooks/subagent_stop_gate.py"
}
]
}
],
"PreCompact": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "python3 .claude/hooks/pre_compact.py"
}
]
}
],
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "cat .aipass/aipass_global_prompt.md 2>/dev/null || true"
}
]
},
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "python3 -c \"from pathlib import Path; p=next((x/'.aipass'/'aipass_local_prompt.md' for x in [Path.cwd(),*Path.cwd().parents] if (x/'.aipass'/'aipass_local_prompt.md').exists()),None); p and print(p.read_text(encoding='utf-8'),end='')\""
}
]
},
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "python3 .claude/hooks/branch_prompt_loader.py"
}
]
},
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "python3 .claude/hooks/email_notification.py"
}
]
},
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "python3 .claude/hooks/identity_injector.py"
}
]
}
]
},
"env": {
"AIPASS_HOME": "/home/patrick/Projects/AIPass"
}
}
+30
View File
@@ -0,0 +1,30 @@
<!-- Source: /home/patrick/Projects/AIPass/src/aipass/aipass/AGENTS.md -->
# AIPASS — Agent Instructions
This project uses AIPass, a multi-agent framework.
## Key Concepts
- **Project** — this directory. Contains a registry and one or more agents.
- **Agent** — a citizen that lives inside the project with its own identity, memory, and code.
- **Registry** — `AIPASS_REGISTRY.json` tracks all agents.
## Getting Started
Create your first agent:
```
aipass init agent <name>
```
## Available Commands
```
aipass init agent <name> # Create a new agent
drone @spawn create <name> # Create agent (alternative)
drone @seedgo audit <project> # Run standards audit
drone systems # List all infrastructure
```
## Startup
On startup, read: `AIPASS_REGISTRY.json`, `README.md`, `STATUS.local.md`
+27
View File
@@ -0,0 +1,27 @@
<!-- Source: /home/patrick/Projects/AIPass/src/aipass/aipass/GEMINI.md -->
# AIPASS — Project Instructions
This project uses AIPass, a multi-agent framework.
## Key Concepts
- **Project** — this directory. Contains a registry and one or more agents.
- **Agent** — a citizen that lives inside the project with its own identity, memory, and code.
- **Registry** — `AIPASS_REGISTRY.json` tracks all agents.
## Getting Started
Create your first agent: `aipass init agent <name>`
## Available Commands
```
aipass init agent <name> # Create a new agent
drone @spawn create <name> # Create agent (alternative)
drone @seedgo audit <project> # Run standards audit
drone systems # List all infrastructure
```
## Startup
On startup, read: `AIPASS_REGISTRY.json`, `README.md`, `STATUS.local.md`
@@ -0,0 +1,31 @@
# =================== AIPass ====================
# Name: __init__.py
# Description: Init handler package — public API
# Version: 1.0.0
# Created: 2026-05-04
# Modified: 2026-05-04
# =============================================
"""Init handler package — public entry point for bootstrap and scaffold_content."""
from aipass.aipass.apps.handlers.init.bootstrap import (
_sanitize_name,
init_project,
update_project,
)
from aipass.aipass.apps.handlers.init.scaffold_content import (
global_prompt_md,
inbox_json,
prep_md,
with_source,
)
__all__ = [
"_sanitize_name",
"global_prompt_md",
"inbox_json",
"init_project",
"prep_md",
"update_project",
"with_source",
]
@@ -0,0 +1,109 @@
<!-- Source: /home/patrick/Projects/AIPass/src/aipass/aipass/status/.aipass/aipass_global_prompt.md -->
# STATUS — Project Context
<!-- Injected every turn via hook. -->
## What is AIPass
AIPass is a multi-agent framework. Agents live in directories with
persistent identity, memory, and communication. All AIPass infrastructure
is available from any project via the `drone` command.
## Terminology
- **Project** — this directory. Contains a registry and agents.
- **Agent** — a citizen with identity (`.trinity/`), memory, mailbox,
and code (`apps/`).
- **Registry** — `STATUS_REGISTRY.json` tracks all agents.
## Setup: if drone commands fail
If `drone` cannot find the AIPass registry, set the env var:
```bash
export AIPASS_HOME=/path/to/AIPass # path to AIPass installation
```
Add to your shell profile (`~/.bashrc` or `~/.zshrc`) to make it permanent.
## Commands
### Agent Lifecycle
```
aipass init agent <name> # Create a new agent in src/<name>/
drone @spawn create <name> # Create agent (alternative)
drone @spawn list # List registered agents
```
### Standards
```
drone @seedgo audit <project> # Run full standards audit
drone @seedgo checklist <file> # Check a single file
```
### Dispatch — Send Task + Wake an Agent (DEFAULT)
```
drone @ai_mail dispatch @<agent> "Subject" "Body" # Send + wake (default)
drone @ai_mail dispatch @<agent> "Subject" "Body" --fresh # Send + wake fresh session
drone @ai_mail dispatch wake @<agent> # Wake without sending
drone @ai_mail dispatch wake --fresh @<agent> # Wake fresh
drone @ai_mail email @<agent> "Subject" "Body" # FYI only (no wake)
```
Use `dispatch` by default. Use `email` only when you don't need the agent to act now.
### Communication (ai_mail)
```
drone @ai_mail inbox # Check your mailbox
drone @ai_mail view <id> # Read a message
drone @ai_mail close <id> # Mark message read
```
### Feedback
```
drone @devpulse feedback send "Subject" "Body" # Send feedback (cross-project)
```
### Plans (flow)
```
drone @flow create . "Subject" dplan # Create DPLAN (design/thinking)
drone @flow create . "Subject" master # Create FPLAN master (execution)
drone @flow create . "Subject" aplan # Create APLAN (agent-level task)
drone @flow list open # List active plans
drone @flow list # List all plans
drone @flow close <id> # Close a plan
drone @flow info <id> # View plan details
```
**DPLAN** = Dev Plan. Thinking, brainstorming, architecture decisions. Use before building.
**FPLAN** = Flow Plan. Building and executing. Use when the plan is clear and work is underway.
### Memory
```
drone @memory archive # Archive memories to vector store
drone @memory search <query> # Search archived memories
```
### Git Workflow
```
drone @git pr 'description' # Create a pull request
drone @git status # Git status (branch-scoped)
drone @git sync # Sync with main
drone @git lock / unlock # Lock/unlock the repo
```
### Infrastructure
```
drone systems # List all available infrastructure
drone --help # Full drone command reference
```
## Patterns
- **Communication** — agents communicate via `.ai_mail.local/`.
- **Standards** — run `drone @seedgo audit` to check compliance.
- **Identity** — agents have `.trinity/passport.json`. Projects use the registry.
- **Memory** — update `.trinity/local.json` at session end. Memory is presence.
## Maintenance
- **Upgrade scaffold**: `drone @cli aipass init update` refreshes managed project files (hooks, prompts, settings) to latest templates.
- **Entry point**: each agent's `apps/{name}.py` auto-configures `sys.path` and `AIPASS_BRANCH_NAME` env var. If prax logs to `unknown_branch/`, check that these are set.
- **Standalone projects** use `src/{name}/` layout (not `src/aipass/{name}/`). Module discovery adapts automatically.
@@ -0,0 +1,23 @@
# Memory Update
Purpose: Update branch memory files after completing work this session.
## Execution
1. Read `.trinity/passport.json` first — re-absorb your identity, role, and principles before writing memories
2. Review what was done this session (context, recent changes, key decisions)
3. Update each file below as needed
4. Confirm completion — list files updated
## Memory Roles
Each memory file plays a distinct role. Update based on what actually changed this session.
- **`.trinity/passport.json`** — IDENTITY. Who you are: role, capabilities, principles. Only update if identity genuinely evolved this session. Don't touch it just to touch it.
- **`.trinity/local.json`** — YOUR MEMORY. Session history and key_learnings. Add a session entry for significant work. Add key_learnings for facts you'd need next time. Trim oldest sessions if over 20.
- **`.trinity/observations.json`** — YOUR MEMORY OF THE USER. Collaboration insights, preferences, friction points, flow states. Skip entirely if nothing new about the user this session.
- **`STATUS.local.md`** — PUBLIC STATUS BEACON. Current work, known issues, todos, notepad. Auto-synced to central STATUS.md on PR events — this is how other branches see you. Keep Current Work accurate and drop quick notes in the Notepad section.
## If Relevant
- **README.md** — Does it reflect current state? Update if stale.
@@ -0,0 +1,57 @@
# Session Wrap-Up
Purpose: Button up everything at the end of a session — or before a /compact. Memories, plans, git — all tidy. Works for both closing out a chat and preparing for compaction.
**Workflow:** `/prep` → review output → close chat or `/compact`
## Execution
1. Read `.trinity/passport.json` first — re-absorb your identity before writing anything
2. Do ALL of the following, then confirm what was updated
## 1. Memories
Each memory file plays a distinct role. Update based on what actually changed this session.
- **`.trinity/passport.json`** — IDENTITY. Who you are: role, capabilities, principles. Only update if identity genuinely evolved this session.
- **`.trinity/local.json`** — YOUR MEMORY. Add/update session entry with a summary of work done. Add key_learnings for anything learned. Trim oldest sessions if over 20.
- **`.trinity/observations.json`** — YOUR MEMORY OF THE USER. Collaboration insights, preferences, friction points. Skip if nothing new about the user this session.
- **`STATUS.local.md`** — PUBLIC STATUS BEACON. Current work, known issues, todos, notepad. Auto-synced to central STATUS.md on PR events — this is how other branches see you. Keep Current Work accurate.
## 2. Active Plans
- Check any DPLANs or FPLANs referenced in this session
- Update their execution logs, status, decision logs with current state
- If a plan was completed, note it (but don't close — the user does that)
## 3. Git State
- Run `git status` — report uncommitted changes
- If there's a logical commit waiting, suggest it (don't commit without asking)
- Note the current branch and any open PRs
## 4. Inbox
- Run `drone @ai_mail inbox 2>/dev/null` — report any unread emails
- Close any that were already processed but not formally closed
## 5. Loose Ends
- Flag anything in-flight: running background agents, dispatched branches waiting for replies, pending decisions
- If anything can't survive compaction (e.g., agent IDs needed for resume), write it to STATUS.local.md Notepad
## Confirm
List everything updated. Format:
```
Prep complete:
- local.json: [what was added]
- observations.json: [updated / skipped]
- STATUS.local.md: [updated / skipped]
- Plans: [which ones updated]
- Git: [branch, uncommitted count, suggestion]
- Inbox: [count, action taken]
- Loose ends: [any flagged]
Ready to close out or /compact.
```
@@ -0,0 +1,393 @@
#!/usr/bin/env python3
"""
PostToolUse Auto-fix Hook — Detects errors and surfaces them for fixing.
Two-hook system:
PostToolUse (this file) → runs pyright + ruff on edited file, saves errors to state
PreToolUse (pre_edit_gate.py) → blocks edits to OTHER files until errors fixed
Key behaviors:
- Runs py_compile (syntax), ruff lint+format, pyright (type errors) on edited file
- Runs seedgo checklist for AIPass standards
- Saves ruff lint AND pyright errors to state file for PreToolUse gate (hard block)
- Surfaces ALL errors in additionalContext so Claude sees them
Version: 5.2.0
CHANGELOG:
- v5.2.0 (2026-04-20): Save ruff lint errors to state file for hard-block enforcement.
Pre-edit gate now blocks on F401/lint just like type errors.
- v5.1.0 (2026-04-19): Added ruff format --check to surface format drift.
- v5.0.0 (2026-03-17): Replaced mcp__ide__getDiagnostics with direct pyright.
Added state file for PreToolUse gate integration.
Single-file pyright (not whole project).
- v4.3.0 (2026-03-17): Added seedgo checklist integration
- v4.0.0 (2025-11-27): Complete rewrite - actual validation, silent operation
"""
import json
import sys
import subprocess
from pathlib import Path
EDIT_TOOLS = ["Edit", "Write", "MultiEdit", "NotebookEdit"]
LAST_FILE_PATH = Path(__file__).parent / ".last_diagnostics_file"
STATE_FILE = Path(__file__).parent / ".diagnostics_state.json"
SKIP_EXTENSIONS = {".md", ".txt", ".log", ".csv", ".html"}
# AIPass-specific Python patterns to check
PYTHON_PATTERNS = {
"bad_optional": {
"pattern": ": str = None",
"message": "Optional param should use 'str | None = None' pattern"
},
"logger_debug": {
"pattern": "logger.debug(",
"message": "Use logger.info for SystemLogger (logger.debug not supported)"
},
"return_error_msg": {
"pattern": "return error_msg",
"message": "Return None for error states, not error_msg string"
},
"open_no_encoding": {
"pattern": "open(",
"requires_missing": "encoding=",
"message": "open() without encoding='utf-8'"
},
"log_not_log_operation": {
"pattern": ".log(",
"message": "Use log_operation() with success/error params, not .log()"
},
"dict_none_no_check": {
"pattern": "Dict | None",
"message": "Dict | None return: Add None check before using (if result is None: return)"
}
}
# JSON-specific patterns for emoji corruption
JSON_CORRUPTION_CHARS = ['\ufffd', '\x00']
def run_python_checks(file_path: str) -> list[str]:
"""Run actual Python validation - returns list of errors."""
errors = []
# 1. Syntax check with py_compile
try:
result = subprocess.run(
[sys.executable, "-m", "py_compile", file_path],
capture_output=True,
text=True,
timeout=5
)
if result.returncode != 0:
errors.append(f"SYNTAX: {result.stderr.strip()}")
except Exception:
pass
# 2. Ruff check (if available) - fast linter
try:
result = subprocess.run(
["ruff", "check", "--select=E,F,W", "--output-format=text", file_path],
capture_output=True,
text=True,
timeout=10
)
if result.stdout.strip():
for line in result.stdout.strip().split("\n")[:5]:
errors.append(f"LINT: {line}")
except FileNotFoundError:
pass
except Exception:
pass
# 3. Ruff format check — detect format drift
try:
result = subprocess.run(
["ruff", "format", "--check", file_path],
capture_output=True,
text=True,
timeout=10
)
if result.returncode != 0:
errors.append(f"FORMAT: {Path(file_path).name} needs ruff format (run: ruff format {Path(file_path).name})")
except FileNotFoundError:
pass
except Exception:
pass
# 4. AIPass-specific pattern checks
try:
content = Path(file_path).read_text(encoding="utf-8")
lines = content.split("\n")
for check in PYTHON_PATTERNS.values():
pattern = check["pattern"]
message = check["message"]
requires_missing = check.get("requires_missing")
if requires_missing:
if pattern in content and requires_missing not in content:
errors.append(f"PATTERN: {message}")
continue
for line in lines:
stripped = line.strip()
if stripped.startswith(("#", '"', "'")):
continue
if f'"{pattern}' in line or f"'{pattern}" in line:
continue
if pattern in line:
errors.append(f"PATTERN: {message}")
break
except Exception:
pass
return errors
def run_ruff_lint_structured(file_path: str) -> list[dict]:
"""Run ruff check and return structured violations for the state file.
Returns list of {line, message} dicts — same format as pyright errors.
Only non-empty when ruff finds real violations (not format drift).
"""
if '/.claude/hooks/' in file_path:
return []
try:
result = subprocess.run(
["ruff", "check", "--select=E,F,W", "--output-format=json", file_path],
capture_output=True, text=True, timeout=10
)
if not result.stdout.strip():
return []
violations = json.loads(result.stdout)
if not isinstance(violations, list):
return []
errors = []
for v in violations[:10]:
line = v.get("location", {}).get("row", 0)
code = v.get("code", "?")
message = v.get("message", "unknown")[:100]
errors.append({"line": line, "message": f"{code}: {message}"})
return errors
except (FileNotFoundError, json.JSONDecodeError, subprocess.TimeoutExpired, Exception):
return []
def run_pyright_check(file_path: str) -> list[dict]:
"""Run pyright on a single file. Returns list of error dicts."""
# Skip hook files - they don't follow project standards
if '/.claude/hooks/' in file_path:
return []
try:
result = subprocess.run(
[sys.executable, "-m", "pyright", "--outputjson", file_path],
capture_output=True,
text=True,
timeout=15
)
try:
data = json.loads(result.stdout)
except (json.JSONDecodeError, ValueError):
return []
errors = []
for diag in data.get("generalDiagnostics", []):
severity = diag.get("severity", "")
if severity == "error":
line = diag.get("range", {}).get("start", {}).get("line", 0)
message = diag.get("message", "Unknown error")
errors.append({
"line": line,
"message": message[:100]
})
return errors[:10] # Max 10 errors
except FileNotFoundError:
return [] # pyright not installed
except subprocess.TimeoutExpired:
return [] # Timeout — don't block
except Exception:
return []
def save_diagnostics_state(file_path: str, errors: list[dict]):
"""Save type errors to state file for PreToolUse gate."""
try:
if errors:
state = {
"file": str(Path(file_path).resolve()),
"errors": errors
}
STATE_FILE.write_text(json.dumps(state), encoding="utf-8")
else:
# No errors — clear the state
if STATE_FILE.exists():
STATE_FILE.unlink()
except Exception:
pass
def run_json_checks(file_path: str) -> list[str]:
"""Run actual JSON validation - returns list of errors."""
errors = []
try:
content = Path(file_path).read_text(encoding="utf-8")
for char in JSON_CORRUPTION_CHARS:
if char in content:
errors.append(f"EMOJI CORRUPTION: Found corrupted character '{repr(char)}'")
break
try:
data = json.loads(content)
if isinstance(data, dict):
for key in ['allowed_emojis', 'emojis', 'emoji_list']:
if key in data and isinstance(data[key], list):
for item in data[key]:
if isinstance(item, str) and len(item) == 1:
if ord(item) < 128 and item not in '\u2713\u2717':
errors.append(f"EMOJI CORRUPTION: Suspicious char '{item}' in {key}")
break
except json.JSONDecodeError as e:
errors.append(f"JSON SYNTAX: {e.msg} at line {e.lineno}")
except Exception as e:
errors.append(f"READ ERROR: {e!s}")
return errors
def run_seedgo_checklist(file_path: str) -> list[str]:
"""Run seedgo standards checklist — returns violations only."""
if '/.claude/hooks/' in file_path:
return []
try:
result = subprocess.run(
["drone", "@seedgo", "checklist", file_path],
capture_output=True,
text=True,
timeout=15,
cwd=str(Path.home() / "Projects" / "AIPass")
)
if result.returncode != 0:
return []
violations = []
for line in result.stdout.split("\n"):
line = line.strip()
if line.startswith("\u2717"):
violation = line[1:].strip()
if violation:
violations.append(violation)
return violations[:5]
except FileNotFoundError:
return []
except Exception:
return []
def should_skip_file(file_path: str) -> bool:
"""Check if file should be skipped."""
if not file_path:
return True
ext = Path(file_path).suffix.lower()
return ext in SKIP_EXTENSIONS
def is_same_file_as_last(file_path: str) -> bool:
"""Smart batching DISABLED — always recheck.
Previously skipped rechecks on the same file, but this caused
errors introduced on second edit to be missed (state file didn't
exist from first clean edit, so skip triggered). The 1.7s pyright
cost per edit is acceptable for correctness.
"""
return False
def main():
"""Main hook entry point."""
try:
input_data = json.load(sys.stdin)
tool_name = input_data.get("tool_name", "")
tool_input = input_data.get("tool_input", {})
file_path = tool_input.get("file_path", "")
if tool_name not in EDIT_TOOLS:
return
if should_skip_file(file_path):
return
if is_same_file_as_last(file_path):
return
# Collect all errors
errors = []
file_type = ""
if file_path.endswith(".py"):
file_type = "Python"
errors = run_python_checks(file_path)
# Seedgo standards checklist
seedgo_violations = run_seedgo_checklist(file_path)
for v in seedgo_violations:
errors.append(f"SEEDGO: {v}")
# Pyright type errors (single file)
type_errors = run_pyright_check(file_path)
for te in type_errors:
errors.append(f"TYPE: L{te['line']}: {te['message']}")
# Save ruff lint + type errors to state file for PreToolUse gate (hard block)
ruff_lint_errors = run_ruff_lint_structured(file_path)
save_diagnostics_state(file_path, ruff_lint_errors + type_errors)
elif file_path.endswith(".json"):
file_type = "JSON"
errors = run_json_checks(file_path)
else:
return
# Build output
if errors:
error_text = "\n".join(f" - {e}" for e in errors)
context = f"""[AUTO-FIX] {len(errors)} error(s) in {Path(file_path).name}:
{error_text}
Fix these errors in {Path(file_path).name} now. Do not skip or defer."""
output = {
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": context
},
"systemMessage": f"[AUTO-FIX] {len(errors)} error(s) — fix before continuing"
}
print(json.dumps(output))
else:
output = {
"systemMessage": "[diagnostics] ok"
}
print(json.dumps(output))
except Exception:
pass # Silent fail
if __name__ == "__main__":
main()
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""
Branch Prompt Loader — AIPass Public Repo
Injects branch-specific prompts based on CWD. When working in a branch
directory, loads .aipass/aipass_local_prompt.md and outputs it so the
AI sees branch-specific context.
Version: 1.0.0
"""
from pathlib import Path
def find_branch_root() -> Path | None:
"""
Find the branch root directory.
Looks for .trinity/ or .aipass/ as branch indicators.
Stops at the repo root (has pyproject.toml or .git).
"""
cwd = Path.cwd()
search_path = cwd
while search_path.parent != search_path:
# Branch indicators: has .trinity/ (memory files) or apps/ (code)
has_trinity = (search_path / ".trinity").is_dir()
has_apps = (search_path / "apps").is_dir()
if has_trinity or has_apps:
return search_path
# Stop at repo root
if (search_path / "pyproject.toml").exists() or (search_path / ".git").is_dir():
return None
search_path = search_path.parent
return None
def main():
branch_root = find_branch_root()
if branch_root:
prompt_file = branch_root / ".aipass" / "aipass_local_prompt.md"
if prompt_file.exists():
content = prompt_file.read_text().strip()
branch_name = branch_root.name.upper()
print(f"\n# Branch Context: {branch_name}\n<!-- Source: {prompt_file} -->\n{content}")
if __name__ == "__main__":
main()
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""
Email Notification Hook - Notifies of new emails on prompt submit.
Checks the current branch's inbox for unread emails and displays
a notification if any exist.
Version: 1.0.0
"""
import json
from pathlib import Path
def find_repo_root() -> Path | None:
"""Find the repo root (contains pyproject.toml or .git)."""
search = Path.cwd()
while search.parent != search:
if (search / "pyproject.toml").exists() or (search / ".git").is_dir():
return search
search = search.parent
return None
def find_branch_root() -> Path | None:
"""Find the branch root directory by walking up from CWD."""
cwd = Path.cwd()
repo_root = find_repo_root()
if not repo_root:
return None
search_path = cwd
for _ in range(10):
has_trinity = (search_path / ".trinity").is_dir()
has_id = list(search_path.glob("*.id.json"))
has_apps = (search_path / "apps").is_dir()
has_mail = (search_path / ".ai_mail.local").is_dir() or (search_path / "ai_mail.local").is_dir()
if (has_trinity or has_id or has_apps or has_mail) and search_path != repo_root:
return search_path
if search_path == repo_root:
break
parent = search_path.parent
if parent == search_path:
break
search_path = parent
return None
def count_new_emails(branch_root: Path) -> int:
"""Count new (unread) emails in the branch's inbox."""
# Check both patterns: .ai_mail.local (canonical) and ai_mail.local (legacy)
inbox_path = branch_root / ".ai_mail.local" / "inbox.json"
if not inbox_path.exists():
inbox_path = branch_root / "ai_mail.local" / "inbox.json"
if not inbox_path.exists():
return 0
try:
with open(inbox_path, "r", encoding="utf-8") as f:
data = json.load(f)
# Handle both formats: {"messages": [...]} and bare [...]
messages = data if isinstance(data, list) else data.get("messages", [])
count = 0
for msg in messages:
if msg.get("status") == "new":
count += 1
elif msg.get("status") is None and not msg.get("read", False):
count += 1
return count
except (json.JSONDecodeError, OSError):
return 0
def main():
branch_root = find_branch_root()
if not branch_root:
return
new_count = count_new_emails(branch_root)
if new_count > 0:
plural = "s" if new_count != 1 else ""
print(f"You have {new_count} new email{plural} - check with: drone @ai_mail inbox | then: drone @ai_mail view <id> | close with: drone @ai_mail close <id>")
if __name__ == "__main__":
main()
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""
Identity Injector - Injects branch identity on every prompt.
Reads from [BRANCH].id.json and outputs core identity fields.
Finds the branch root by walking up from CWD looking for apps/ or *.id.json.
Version: 1.0.0
"""
import json
from pathlib import Path
def find_repo_root() -> Path | None:
"""Find the repo root (contains pyproject.toml or .git)."""
search = Path.cwd()
while search.parent != search:
if (search / "pyproject.toml").exists() or (search / ".git").is_dir():
return search
search = search.parent
return None
def find_branch_root() -> Path | None:
"""Find the branch root directory by walking up from CWD."""
cwd = Path.cwd()
repo_root = find_repo_root()
if not repo_root:
return None
search_path = cwd
while search_path >= repo_root:
has_trinity = (search_path / ".trinity").is_dir()
has_id = list(search_path.glob("*.id.json"))
if has_trinity or has_id:
return search_path
if search_path == repo_root:
break
search_path = search_path.parent
return None
def find_id_file(branch_root: Path) -> Path | None:
"""Find the identity file for a branch (.trinity/passport.json or *.id.json)."""
# AIPass pattern: .trinity/passport.json
passport = branch_root / ".trinity" / "passport.json"
if passport.exists():
return passport
# Dev-Pass fallback: *.id.json
id_files = list(branch_root.glob("*.id.json"))
if id_files:
return id_files[0]
return None
def format_identity(data: dict) -> str:
"""Format branch_info + identity for injection."""
lines = []
# Try branch_info first (enriched passports), fall back to identity block (setup.sh passports)
branch = data.get("branch_info", {})
identity = data.get("identity", {})
name = branch.get("branch_name") or identity.get("name", "UNKNOWN")
lines.append(f"# {name} Identity")
lines.append(f"Path: {branch.get('path', 'unknown')}")
lines.append(f"Email: {branch.get('email', 'unknown')}")
identity = data.get("identity", {})
if identity.get("role"):
lines.append(f"Role: {identity['role']}")
traits = identity.get("traits") or data.get("traits")
if traits:
if isinstance(traits, list):
lines.append("Traits: " + " | ".join(traits))
else:
lines.append(f"Traits: {traits}")
if identity.get("purpose"):
lines.append(f"Purpose: {identity['purpose']}")
what_i_do = identity.get("what_i_do", [])
if what_i_do:
lines.append("Do: " + " | ".join(what_i_do[:4]))
what_i_dont_do = identity.get("what_i_dont_do", [])
if what_i_dont_do:
lines.append("Don't: " + " | ".join(what_i_dont_do[:3]))
principles = data.get("principles", [])
if principles:
lines.append("Principles: " + " * ".join(principles))
return "\n".join(lines)
def main():
branch_root = find_branch_root()
if not branch_root:
return
id_file = find_id_file(branch_root)
if not id_file or not id_file.exists():
return
try:
data = json.loads(id_file.read_text(encoding="utf-8"))
output = format_identity(data)
if output:
print(f"\n{output}")
except (json.JSONDecodeError, KeyError):
pass
if __name__ == "__main__":
main()
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""
Pre-Compact Hook - Inject live state for post-compact recovery.
Reads STATUS.local.md, last session from local.json, and git branch
to give the model real context after compaction — not generic advice.
Version: 3.0.0
"""
import json
import subprocess
import sys
from pathlib import Path
def _find_branch_dir():
"""Find the current branch directory from CWD."""
cwd = Path.cwd()
# Check if we're in a branch dir or subdirectory of one
# Pattern: .../src/aipass/{branch}/...
parts = cwd.parts
for i, part in enumerate(parts):
if part == "aipass" and i > 0 and parts[i - 1] == "src":
branch_dir = Path(*parts[: i + 2])
if branch_dir.is_dir():
return branch_dir
# Check if CWD itself has .trinity/
if (cwd / ".trinity").is_dir():
return cwd
return None
def _read_status_local(branch_dir):
"""Read STATUS.local.md if it exists."""
for name in ["STATUS.local.md", "dev.local.md"]:
path = branch_dir / name
if path.is_file():
try:
return path.read_text(encoding="utf-8")[:3000]
except Exception:
pass
return None
def _read_last_session(branch_dir):
"""Read the most recent session and key_learnings from local.json."""
local_path = branch_dir / ".trinity" / "local.json"
if not local_path.is_file():
return None
try:
data = json.loads(local_path.read_text(encoding="utf-8"))
result = []
# Last session
sessions = data.get("sessions", [])
if sessions:
last = sessions[0]
result.append(
f"Last session (#{last.get('session_number', '?')}, "
f"{last.get('date', '?')}): {last.get('summary', 'no summary')}"
)
# Key learnings (just the keys, not full values — breadcrumbs)
learnings = data.get("key_learnings", {})
if learnings:
keys = list(learnings.keys())[-10:] # last 10
result.append(f"Key learnings available: {', '.join(keys)}")
return "\n".join(result) if result else None
except Exception:
return None
def _get_git_info():
"""Get current git branch and short status."""
try:
branch = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
text=True,
timeout=5,
)
status = subprocess.run(
["git", "diff", "--stat", "--cached", "HEAD"],
capture_output=True,
text=True,
timeout=5,
)
dirty = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True,
text=True,
timeout=5,
)
result = []
if branch.returncode == 0:
result.append(f"Git branch: {branch.stdout.strip()}")
if dirty.returncode == 0 and dirty.stdout.strip():
lines = dirty.stdout.strip().split("\n")
result.append(f"Uncommitted changes: {len(lines)} files")
return "\n".join(result) if result else None
except Exception:
return None
def _get_branch_name(branch_dir):
"""Extract branch name from directory."""
return branch_dir.name if branch_dir else "unknown"
def main():
"""Main hook entry point."""
try:
json.load(sys.stdin)
branch_dir = _find_branch_dir()
branch_name = _get_branch_name(branch_dir)
sections = []
sections.append(f"""POST-COMPACT RECOVERY — @{branch_name}
Context just compacted. Below is your live state. Use it to continue seamlessly.""")
# Git info
git_info = _get_git_info()
if git_info:
sections.append(f"## Git\n{git_info}")
# Last session from local.json
if branch_dir:
session_info = _read_last_session(branch_dir)
if session_info:
sections.append(f"## Last Session\n{session_info}")
# STATUS.local.md — the main context
if branch_dir:
status = _read_status_local(branch_dir)
if status:
sections.append(f"## STATUS.local.md\n{status}")
# Recovery instructions (lean)
sections.append("""## Recovery Protocol
- Continue where the summary left off — don't restart or ask generic questions
- .trinity/local.json has full session history and key_learnings — read it if you need more context
- STATUS.local.md has current work, known issues, and todos
- Save memories proactively — compaction just proved you need to
- Match the conversation tone from before compaction""")
print("\n\n".join(sections), file=sys.stdout)
print("Pre-compact: live state injected", file=sys.stderr)
except Exception as e:
# Fail silently — never block compaction
print(f"Pre-compact hook error: {e}", file=sys.stderr)
sys.exit(0)
if __name__ == "__main__":
main()
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""
PreToolUse Gate — Blocks unsafe edits at the hook layer.
Rules (checked in order):
1. Inbox lock — any write targeting *.ai_mail.local/inbox.json is BLOCKED.
Use `drone @ai_mail email` instead.
2. Cross-branch — writes to src/aipass/X/** from a CWD inside src/aipass/Y/**
are BLOCKED unless the calling branch is in TRUSTED_CROSS_WRITERS.
3. State-file — edits to OTHER .py files while the current branch has unresolved
type errors are BLOCKED. (original v1.2.0 logic)
Track E additions: rules 1 + 2 (DPLAN-0139).
Version: 1.3.0
"""
import json
import os
import sys
from pathlib import Path
STATE_FILE = Path(__file__).parent / ".diagnostics_state.json"
EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
# Single source of truth lives in permissions.py — inline here as fallback
# so the hook works even when aipass package is not on sys.path.
TRUSTED_CROSS_WRITERS: tuple[str, ...] = ("devpulse", "seedgo", "spawn")
def _get_branch(file_path: str) -> str:
"""Extract AIPass branch name from a file path (src/aipass/{branch}/ pattern)."""
parts = Path(file_path).parts
for i, part in enumerate(parts):
if part == "aipass" and i > 0 and parts[i - 1] == "src" and i + 1 < len(parts):
return parts[i + 1]
return ""
def _block(reason: str) -> None:
print(json.dumps({"decision": "block", "reason": reason}))
sys.exit(2)
def main():
try:
input_data = json.load(sys.stdin)
tool_name = input_data.get("tool_name", "")
tool_input = input_data.get("tool_input", {})
file_path = tool_input.get("file_path", "")
if tool_name not in EDIT_TOOLS:
return
if not file_path:
return
# ------------------------------------------------------------------
# Rule 1: Inbox lock — block all writes to *.ai_mail.local/inbox.json
# ------------------------------------------------------------------
fp = Path(file_path)
if fp.name == "inbox.json" and ".ai_mail.local" in fp.parts:
_block('Direct writes to inbox.json are blocked.\nUse: drone @ai_mail email @<branch> "Subject" "Body"')
# ------------------------------------------------------------------
# Rule 1.5: Dispatched-agent path confinement (DPLAN-0155 M3)
# Daemon-spawned agents can only write inside their own branch dir.
# Breaks the prompt-injection amplifier chain — even if injected,
# a dispatched agent cannot write to other agents' inboxes or code.
# ------------------------------------------------------------------
cwd = input_data.get("cwd", "") or os.getcwd()
cwd_branch = _get_branch(cwd)
session_type = os.environ.get("AIPASS_SESSION_TYPE", "interactive")
if session_type == "daemon" and cwd_branch:
target_branch = _get_branch(str(fp.resolve()) if not fp.is_absolute() else str(fp))
if target_branch and target_branch != cwd_branch:
_block(
f"Dispatched agent confined to own branch: '{cwd_branch}' "
f"cannot write to '{target_branch}' in daemon mode."
)
repo_root = None
for parent in Path(cwd).parents:
if (parent / ".git").exists():
repo_root = parent
break
if repo_root and not target_branch:
allowed_prefix = str(repo_root / "src" / "aipass" / cwd_branch)
resolved = str(fp.resolve()) if not fp.is_absolute() else str(fp)
if not resolved.startswith(allowed_prefix):
_block(f"Dispatched agent restricted to {allowed_prefix}. Cannot write to: {file_path}")
# ------------------------------------------------------------------
# Rule 2: Cross-branch write enforcement
# ------------------------------------------------------------------
target_branch = _get_branch(str(fp.resolve()) if not fp.is_absolute() else str(fp))
if cwd_branch and target_branch and cwd_branch != target_branch:
if cwd_branch not in TRUSTED_CROSS_WRITERS:
_block(
f"Cross-branch write blocked: '{cwd_branch}' cannot write to '{target_branch}'.\n"
f"Trusted cross-writers: {', '.join(TRUSTED_CROSS_WRITERS)}"
)
# ------------------------------------------------------------------
# Rule 3: State-file (original v1.2.0) — .py files only
# ------------------------------------------------------------------
if not file_path.endswith(".py"):
return
if not STATE_FILE.exists():
return
try:
state = json.loads(STATE_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, IOError):
return
errored_file = state.get("file", "")
errors = state.get("errors", [])
if not errors:
return
try:
current = str(Path(file_path).resolve())
errored = str(Path(errored_file).resolve())
except (OSError, ValueError):
return
if current == errored:
return
current_branch = _get_branch(current)
errored_branch = _get_branch(errored)
if not errored_branch:
return
if current_branch and errored_branch and current_branch != errored_branch:
return
error_summary = "\n".join(f" L{e['line']}: {e['message']}" for e in errors[:5])
_block(f"Fix {len(errors)} error(s) in {Path(errored_file).name} before editing other files:\n{error_summary}")
except Exception:
pass # Silent fail → allow
if __name__ == "__main__":
main()
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""
SubagentStop Gate — Checks files modified by subagents before allowing them to finish.
Runs seedgo checklist + basic validation on any .py files the subagent touched.
If violations found, blocks the stop and tells the subagent to fix them.
Version: 1.0.0
"""
import json
import os
import sys
import subprocess
from pathlib import Path
def _find_repo_root() -> Path | None:
"""Walk up from CWD or AIPASS_HOME to find the git repo root."""
for start in (os.environ.get("AIPASS_HOME", ""), os.getcwd()):
p = Path(start)
while p != p.parent:
if (p / ".git").exists():
return p
p = p.parent
return None
AIPASS_ROOT = _find_repo_root()
def get_modified_py_files() -> list[str]:
"""Get Python files modified in the working tree (unstaged + staged)."""
if AIPASS_ROOT is None:
return []
try:
result = subprocess.run(
["git", "diff", "--name-only", "HEAD"], capture_output=True, text=True, timeout=5, cwd=str(AIPASS_ROOT)
)
files = []
for line in result.stdout.strip().split("\n"):
line = line.strip()
if line.endswith(".py") and not line.startswith(".claude/"):
full = AIPASS_ROOT / line
if full.exists():
files.append(str(full))
return files
except Exception:
return []
def run_seedgo_checklist(file_path: str) -> list[str]:
"""Run seedgo checklist on a single file."""
if AIPASS_ROOT is None:
return []
if "/.claude/" in file_path:
return []
try:
result = subprocess.run(
["drone", "@seedgo", "checklist", file_path],
capture_output=True,
text=True,
timeout=15,
cwd=str(AIPASS_ROOT),
)
if result.returncode != 0:
return []
violations = []
for line in result.stdout.split("\n"):
line = line.strip()
if line.startswith("\u2717"):
v = line[1:].strip()
if v:
violations.append(v)
return violations[:5]
except Exception:
return []
def main():
try:
input_data = json.load(sys.stdin)
modified = get_modified_py_files()
if not modified:
return # Nothing to check
all_violations = {}
for f in modified:
vs = run_seedgo_checklist(f)
if vs:
name = Path(f).name
all_violations[name] = vs
if not all_violations:
return # All clear
# Build the block reason
lines = ["Standards violations found in files you modified:\n"]
for fname, vs in all_violations.items():
lines.append(f" {fname}:")
for v in vs:
lines.append(f" - {v}")
lines.append("\nFix these violations before finishing.")
output = {"decision": "block", "reason": "\n".join(lines)}
print(json.dumps(output))
except Exception:
pass # Silent fail — don't block on errors
if __name__ == "__main__":
main()
@@ -0,0 +1,98 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "python3 .claude/hooks/auto_fix_diagnostics.py"
}
]
}
],
"PreToolUse": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "python3 .claude/hooks/pre_edit_gate.py"
}
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "python3 .claude/hooks/subagent_stop_gate.py"
}
]
}
],
"PreCompact": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "python3 .claude/hooks/pre_compact.py"
}
]
}
],
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "cat .aipass/aipass_global_prompt.md 2>/dev/null || true"
}
]
},
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "python3 -c \"from pathlib import Path; p=next((x/'.aipass'/'aipass_local_prompt.md' for x in [Path.cwd(),*Path.cwd().parents] if (x/'.aipass'/'aipass_local_prompt.md').exists()),None); p and print(p.read_text(encoding='utf-8'),end='')\""
}
]
},
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "python3 .claude/hooks/branch_prompt_loader.py"
}
]
},
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "python3 .claude/hooks/email_notification.py"
}
]
},
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "python3 .claude/hooks/identity_injector.py"
}
]
}
]
},
"env": {
"AIPASS_HOME": "/home/patrick/Projects/AIPass"
}
}
+37
View File
@@ -0,0 +1,37 @@
# AIPass local state
.trinity/
.ai_mail.local/
*.local.*
!STATUS.local.md
# Plans (local working docs)
DPLAN-*
FPLAN-*
APLAN-*
TDPLAN-*
# Logs
logs/
# Python
__pycache__/
*.py[cod]
*.egg-info/
dist/
build/
.venv/
venv/
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# Archives
.archive/
# Disabled files
*(disabled)*
+30
View File
@@ -0,0 +1,30 @@
<!-- Source: /home/patrick/Projects/AIPass/src/aipass/aipass/status/AGENTS.md -->
# STATUS — Agent Instructions
This project uses AIPass, a multi-agent framework.
## Key Concepts
- **Project** — this directory. Contains a registry and one or more agents.
- **Agent** — a citizen that lives inside the project with its own identity, memory, and code.
- **Registry** — `STATUS_REGISTRY.json` tracks all agents.
## Getting Started
Create your first agent:
```
aipass init agent <name>
```
## Available Commands
```
aipass init agent <name> # Create a new agent
drone @spawn create <name> # Create agent (alternative)
drone @seedgo audit <project> # Run standards audit
drone systems # List all infrastructure
```
## Startup
On startup, read: `STATUS_REGISTRY.json`, `README.md`, `STATUS.local.md`
+41
View File
@@ -0,0 +1,41 @@
<!-- Source: /home/patrick/Projects/AIPass/src/aipass/aipass/status/CLAUDE.md -->
# STATUS
**User:** (your name here)
## What is AIPass
AIPass is a multi-agent framework. This project was created with `aipass init`.
**Key concepts:**
- **Project** — this directory. Contains a registry and one or more agents.
- **Agent** — a citizen that lives inside the project. Has identity (`.trinity/`), memory, mailbox, and its own apps/ directory.
- **Registry** — `STATUS_REGISTRY.json` tracks all agents in this project.
## Getting Started
Create your first agent:
```
aipass init agent <name>
```
This creates a full agent scaffold inside `src/<name>/` (`apps/`, `.trinity/`, `.ai_mail.local/`) and registers it in your project registry.
## Available Commands
```
aipass init agent <name> # Create a new agent
drone @spawn create <name> # Create agent (alternative)
drone @seedgo audit <project> # Run standards audit
drone @ai_mail inbox # Check mailbox (per-agent)
drone systems # List all available infrastructure
```
## Startup Protocol
On any greeting, silently read these files — no narration, just do it and respond with the status.
**Read:** `STATUS_REGISTRY.json`, `README.md`, `STATUS.local.md`
**Run:** `git status`
Then check the registry for agents and report status.
+27
View File
@@ -0,0 +1,27 @@
<!-- Source: /home/patrick/Projects/AIPass/src/aipass/aipass/status/GEMINI.md -->
# STATUS — Project Instructions
This project uses AIPass, a multi-agent framework.
## Key Concepts
- **Project** — this directory. Contains a registry and one or more agents.
- **Agent** — a citizen that lives inside the project with its own identity, memory, and code.
- **Registry** — `STATUS_REGISTRY.json` tracks all agents.
## Getting Started
Create your first agent: `aipass init agent <name>`
## Available Commands
```
aipass init agent <name> # Create a new agent
drone @spawn create <name> # Create agent (alternative)
drone @seedgo audit <project> # Run standards audit
drone systems # List all infrastructure
```
## Startup
On startup, read: `STATUS_REGISTRY.json`, `README.md`, `STATUS.local.md`
+53
View File
@@ -0,0 +1,53 @@
# STATUS
An AIPass project.
## Quick Start
```bash
# 1. Create your first agent
aipass init agent my_agent
# 2. Start a session
cd src/my_agent/
claude # or your preferred AI CLI
# 3. Check project status
cat STATUS.local.md
```
## Project Structure
```
status/
STATUS_REGISTRY.json # Agent registry
.aipass/ # Prompts (injected per-turn)
CLAUDE.md # Claude Code instructions
AGENTS.md # Codex instructions
GEMINI.md # Gemini instructions
STATUS.local.md # Project status
src/ # Agent directories live here
<agent_name>/ # Created via aipass init agent
```
## What is AIPass?
AIPass is a multi-agent framework where autonomous agents (citizens) live in directories with persistent identity, memory, and communication.
Each agent has:
- **Identity** — `.trinity/passport.json`
- **Memory** — `.trinity/local.json`, `observations.json`
- **Mailbox** — `.ai_mail.local/`
- **Code** — `apps/` with modules and handlers
## Commands
| Command | Description |
|---------|-------------|
| `aipass init agent <name>` | Create a new agent |
| `drone @spawn create <name>` | Create agent (alternative) |
| `drone @seedgo audit <project>` | Run standards audit |
| `drone @ai_mail inbox` | Check agent mailbox |
| `drone systems` | List infrastructure |
*Initialized with [AIPass](https://github.com/AIOSAI/AIPass) on 2026-05-04*
+9
View File
@@ -0,0 +1,9 @@
# STATUS
**State:** New
**Last update:** 2026-05-04
## Current Work
## Known Issues
- None
@@ -0,0 +1,11 @@
{
"metadata": {
"id": "66ae9e4d-e0b0-408c-8944-259b8dcdb6a8",
"name": "STATUS",
"version": "1.0.0",
"created": "2026-05-04",
"last_updated": "2026-05-04",
"total_branches": 0
},
"branches": []
}
@@ -1,4 +1,12 @@
"""Tests for the CLI init bootstrap handler.
# =================== AIPass ====================
# Name: test_bootstrap.py
# Description: Tests for init bootstrap handler (DPLAN-0164)
# Version: 1.0.0
# Created: 2026-05-04
# Modified: 2026-05-04
# =============================================
"""Tests for the init bootstrap handler.
Covers _sanitize_name(), init_project(), update_project(), and
scaffold_content generators — all file operations use tmp_path to
@@ -11,8 +19,11 @@ from datetime import date
import pytest
from aipass.cli.apps.handlers.init.bootstrap import _sanitize_name, init_project, update_project
from aipass.cli.apps.handlers.init import scaffold_content as sc
from aipass.aipass.apps.handlers.init import bootstrap, scaffold_content as sc
_sanitize_name = bootstrap._sanitize_name
init_project = bootstrap.init_project
update_project = bootstrap.update_project
# ---------------------------------------------------------------------------
@@ -821,7 +832,7 @@ def test_init_project_hooks_not_shipped_without_aipass_home(tmp_path, monkeypatc
target.mkdir()
monkeypatch.setattr(
"aipass.cli.apps.handlers.init.bootstrap._detect_aipass_home",
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
lambda: None,
)
+27 -15
View File
@@ -198,12 +198,13 @@ class TestHandleCommand:
assert handle_command("doctor", []) is False
assert handle_command("profile", ["set", "name", "X"]) is False
def test_no_args_calls_introspection(self, tmp_local_json) -> None:
"""'init' with no args calls print_introspection."""
with patch("aipass.aipass.apps.modules.init_flow.print_introspection") as mock_pi:
result = handle_command("init", [])
assert result is True
mock_pi.assert_called_once()
def test_no_args_runs_scaffold(self, tmp_local_json) -> None:
"""'init' with no args runs project scaffold."""
with patch("aipass.aipass.apps.modules.init_flow._preflight_check", return_value=None):
with patch("aipass.aipass.apps.modules.init_flow._handle_init_scaffold", return_value=0):
with pytest.raises(SystemExit) as exc_info:
handle_command("init", [])
assert exc_info.value.code == 0
def test_help_flag(self) -> None:
"""--help flag routes to print_help."""
@@ -221,17 +222,21 @@ class TestHandleCommand:
def test_run_subcommand_calls_run_init(self, tmp_local_json) -> None:
"""'init run' routes to run_init."""
with patch("aipass.aipass.apps.modules.init_flow.run_init", return_value=0) as mock_run:
result = handle_command("init", ["run"])
assert result is True
with patch("aipass.aipass.apps.modules.init_flow._preflight_check", return_value=None):
with patch("aipass.aipass.apps.modules.init_flow.run_init", return_value=0) as mock_run:
with pytest.raises(SystemExit) as exc_info:
handle_command("init", ["run"])
assert exc_info.value.code == 0
mock_run.assert_called_once()
def test_unknown_subcommand_falls_to_print_help(self, tmp_local_json) -> None:
"""Unknown subcommand falls through to print_help."""
with patch("aipass.aipass.apps.modules.init_flow.print_help") as mock_help:
result = handle_command("init", ["status"])
assert result is True
mock_help.assert_called_once()
def test_positional_args_treated_as_scaffold_target(self, tmp_local_json) -> None:
"""Positional args are treated as target path for scaffold."""
with patch("aipass.aipass.apps.modules.init_flow._preflight_check", return_value=None):
with patch("aipass.aipass.apps.modules.init_flow._handle_init_scaffold", return_value=0) as mock_scaffold:
with pytest.raises(SystemExit) as exc_info:
handle_command("init", ["/tmp/test-proj"])
assert exc_info.value.code == 0
mock_scaffold.assert_called_once_with(["/tmp/test-proj"])
# =============================================================================
@@ -239,6 +244,13 @@ class TestHandleCommand:
# =============================================================================
@pytest.fixture(autouse=True)
def _bypass_preflight():
"""All TestRunInit tests run outside a real AIPass project — bypass the guard."""
with patch("aipass.aipass.apps.modules.init_flow._preflight_check", return_value=None):
yield
class TestRunInit:
def _patch_all_stages(self):
"""Context manager that patches all 12 stage functions to no-ops."""
+1 -45
View File
@@ -5,41 +5,6 @@
"description": "Standards bypass configuration for this branch"
},
"bypass": [
{
"file": "apps/handlers/init/bootstrap.py",
"standard": "json_structure",
"reason": "Pure Python bootstrap — no branch imports by design"
},
{
"file": "apps/handlers/init/scaffold_content.py",
"standard": "json_structure",
"reason": "Pure content generators returning strings — no operations to log, no branch imports by design."
},
{
"file": "apps/handlers/init/bootstrap.py",
"standard": "silent_catch",
"reason": "Pure Python bootstrap — cannot import prax (circular: prax depends on cli). _detect_aipass_home() catches detection failures silently by design."
},
{
"file": "apps/handlers/init/bootstrap.py",
"standard": "error_handling",
"reason": "Same silent catch as silent_catch standard — already bypassed (pure Python, no prax import allowed)."
},
{
"file": "apps/handlers/init/bootstrap.py",
"standard": "log_visibility",
"reason": "Pure Python bootstrap — cannot import prax system_logger (circular: prax depends on cli). Uses stdlib logging as fallback."
},
{
"file": "apps/handlers/init/bootstrap.py",
"standard": "help_text",
"reason": "python3 reference is inside a shell command string assigned to _local_prompt_cmd — not help text, it is the cross-platform hook command itself."
},
{
"file": "apps/handlers/init/bootstrap.py",
"standard": "debug_print",
"reason": "print() reference is inside a shell command string assigned to _local_prompt_cmd — not a Python print() call, it is part of the inline python3 -c hook command."
},
{
"file": "tests/test_bootstrap.py",
"standard": "architecture",
@@ -101,12 +66,6 @@
"pattern": "__all__",
"reason": "Python dunder convention — __all__ controls public API exports, not a constant to uppercase"
},
{
"file": "apps/modules/init_project.py",
"standard": "naming",
"pattern": "__all__",
"reason": "Python dunder convention — __all__ controls public API exports, not a constant to uppercase"
},
{
"file": "drone_adapter.py",
"standard": "unused_function",
@@ -154,10 +113,7 @@
"file": "apps/modules/logger.py",
"standard": "cli",
"reason": "Circular dependency - logger cannot import CLI",
"lines": [
146,
177
],
"lines": [146, 177],
"pattern": "if __name__ == '__main__'"
},
"fields": {
+10 -49
View File
@@ -2,12 +2,12 @@
# CLI
**Purpose:** Display and output formatting service for all AIPass branches, plus the `aipass init` project bootstrap command. Provides consistent terminal output — headers, success/error/warning messages, section breaks, and operation templates — so every branch looks the same without duplicating Rich formatting code.
**Purpose:** Display and output formatting service for all AIPass branches. Provides consistent terminal output — headers, success/error/warning messages, section breaks, and operation templates — so every branch looks the same without duplicating Rich formatting code.
**Module:** `aipass.cli`
**Version:** 2.0.0
**Version:** 2.1.0
**Seedgo:** 100% (34/34 standards)
**Tests:** 203 passing (6 files, 5/5 modules covered)
**Last Updated:** 2026-04-26
**Last Updated:** 2026-05-04
## Usage
@@ -81,16 +81,8 @@ from aipass.cli.apps.modules.display import header # Direct
```bash
# Via drone
drone @cli --help # Services + Rich formatting showcase
drone @cli --version # Version (v2.0.0)
drone @cli --version # Version (v2.1.0)
drone @cli # Module discovery (introspection)
drone @cli aipass # Show aipass subcommands
drone @cli aipass init # Bootstrap AIPass project in current dir
drone @cli aipass init /path # Bootstrap in target directory
drone @cli aipass init /path MyProject # Bootstrap with custom name
drone @cli aipass init update # Re-sync managed scaffold files
drone @cli aipass init update /path # Re-sync in target directory
drone @cli aipass init agent <name> # Create agent (routes to spawn)
drone @cli aipass init --help # Detailed init usage
drone @cli display # Display module info
drone @cli display demo # Run display function showcase
drone @cli templates # Templates module info
@@ -98,35 +90,13 @@ drone @cli templates demo # Run templates function showcase
# Standalone (no drone required)
python -m aipass.cli --help # Same help output
python -m aipass.cli aipass init /path # Bootstrap directly
aipass --help # Via console_scripts entry point
```
## aipass init
> **Note:** The `aipass init` command is now owned by the @aipass branch (`aipass.aipass.apps.aipass:main`).
Bootstraps a new AIPass project with 21 items (when AIPASS_HOME is detected):
## aipass init (moved)
| Category | Items Created |
|----------|---------------|
| Identity | `*_REGISTRY.json` |
| Prompts | `.aipass/aipass_global_prompt.md`, `CLAUDE.md`, `AGENTS.md`, `GEMINI.md` |
| Docs | `README.md`, `STATUS.local.md` |
| Config | `.claude/settings.json`, `.gitignore` |
| Slash commands | `.claude/commands/prep.md`, `.claude/commands/memo.md` |
| Hooks | 7 enforcement/injector hooks in `.claude/hooks/` |
| Dirs | `hooks/`, `src/` |
| Mail | `.ai_mail.local/inbox.json` |
Settings.json wires 5 hook event types:
- **UserPromptSubmit** (5 hooks): global prompt, local prompt, branch prompt loader, email notification, identity injector
- **PostToolUse**: auto-fix diagnostics
- **PreToolUse**: pre-edit gate
- **Stop**: subagent stop gate
- **PreCompact**: pre-compact
`aipass init update` re-syncs managed files (prompts, config, hooks) without touching user-owned files (registry, README, STATUS.local.md, .gitignore, mailbox).
Cross-platform: local prompt discovery uses `python3 -c` with pathlib (no bash dependency). A `setup.py` installer handles Windows + Linux + macOS.
The `aipass init` command has been moved to the @aipass branch. CLI no longer owns project bootstrapping. See `aipass.aipass.apps.aipass:main` for the current implementation.
## Architecture
@@ -139,20 +109,14 @@ cli/
│ ├── modules/ # PUBLIC — import from here
│ │ ├── __init__.py # Re-exports all display + template functions
│ │ ├── display.py # header, success, error, warning, fatal, section
│ │ ├── templates.py # operation_start, operation_complete
│ │ └── init_project.py # aipass init command routing
│ │ └── templates.py # operation_start, operation_complete
│ └── handlers/ # PRIVATE — internal implementation
│ ├── init/
│ │ ├── bootstrap.py # init_project(), update_project() (511 lines)
│ │ └── scaffold_content.py # 10 content generators (502 lines)
│ ├── json/
│ │ └── json_handler.py # JSON lifecycle (CRUD, validation, rotation)
│ └── templates/ # Empty — placeholder from scaffold
├── tests/ # 203 tests across 6 files
│ ├── test_bootstrap.py # 72 tests — init/update/hooks/memo/mailbox/scaffold
├── tests/ # Test suite
│ ├── test_json_handler.py # 35 tests — CRUD, validation, rotation
│ ├── test_display.py # 28 tests — all display functions + routing
│ ├── test_init_project.py # 34 tests — command routing, agent, update, output
│ ├── test_templates.py # 19 tests — operation templates + routing
│ └── test_integration.py # 8 tests — main() flow, entry points
├── cli_json/ # Auto-created JSON (config, data, log)
@@ -190,8 +154,6 @@ json_handler.ensure_module_jsons("cli") # Create all 3 if missing
- **All branches** — Display formatting (header, success, error, warning, fatal, section)
- **All branches** — Operation templates (operation_start, operation_complete)
- **All branches** — Rich console access
- **All users** — `aipass init` project bootstrap + `aipass init update` refresh
- **All users** — `aipass init agent` routing to spawn
## Entry Points
@@ -199,11 +161,10 @@ json_handler.ensure_module_jsons("cli") # Create all 3 if missing
|-------|---------|-----|
| drone | `drone @cli [command]` | Drone routes to `apps/cli.py:main()` |
| Module | `python -m aipass.cli [args]` | `__main__.py` calls `main()` |
| PATH | `aipass [args]` | `console_scripts` calls `cli_entry()` |
---
*Last Updated: 2026-04-26*
*Last Updated: 2026-05-04*
---
[← Back to AIPass](../../../README.md)
+6 -12
View File
@@ -1,16 +1,15 @@
# =================== AIPass ====================
# Name: cli.py
# Description: Entry point for drone @cli — seedgo-compliant module discovery and routing
# Version: 2.0.0
# Version: 2.1.0
# Created: 2026-03-08
# Modified: 2026-03-15
# Modified: 2026-05-04
# =============================================
"""
CLI Branch - Universal Display/Output Service Provider
Routes commands to auto-discovered modules via seedgo pattern.
- 'aipass init /path' -> init_project module (handle_command)
- Modules auto-discovered from modules/ directory
- Service modules (display, templates) also discoverable via handle_command()
@@ -40,7 +39,7 @@ from rich import box
# CLI modules (showcasing our own services!)
from aipass.cli.apps.modules.display import console as CONSOLE, header, error
VERSION = "2.0.0"
VERSION = "2.1.0"
CLI_ROOT = Path(__file__).parent
MODULES_DIR = CLI_ROOT / "modules"
@@ -138,8 +137,8 @@ def print_introspection() -> None:
CONSOLE.print()
CONSOLE.print("[yellow]Next:[/yellow] Explore a module")
CONSOLE.print(" [green]drone @cli aipass[/green] [dim]# Project commands[/dim]")
CONSOLE.print(" [green]drone @cli aipass init --help[/green] [dim]# Bootstrap a project[/dim]")
CONSOLE.print(" [green]drone @cli display[/green] [dim]# Display module info[/dim]")
CONSOLE.print(" [green]drone @cli display demo[/green] [dim]# Run display showcase[/dim]")
CONSOLE.print(" [green]drone @cli --help[/green] [dim]# Full usage guide[/dim]")
CONSOLE.print()
@@ -177,9 +176,6 @@ def print_help() -> None:
CONSOLE.print("[bold cyan]COMMANDS:[/bold cyan]")
CONSOLE.print()
CONSOLE.print(" [green]drone @cli[/green] [dim]# Show discovered modules[/dim]")
CONSOLE.print(" [green]drone @cli aipass[/green] [dim]# Project commands[/dim]")
CONSOLE.print(" [green]drone @cli aipass init[/green] [dim]# Bootstrap a project[/dim]")
CONSOLE.print(" [green]drone @cli aipass init /path MyProj[/green] [dim]# Bootstrap with name[/dim]")
CONSOLE.print(" [green]drone @cli display[/green] [dim]# Display module info[/dim]")
CONSOLE.print(" [green]drone @cli display demo[/green] [dim]# Run display demo[/dim]")
CONSOLE.print(" [green]drone @cli templates[/green] [dim]# Templates module info[/dim]")
@@ -237,10 +233,8 @@ def print_help() -> None:
[green]\u2713[/green] apps/modules/ = PUBLIC API (what branches import)
- display.py Display functions (header, success, error, etc.)
- templates.py Standard operation patterns
- init_project.py Project bootstrap (aipass init)
[green]\u2713[/green] apps/handlers/ = PRIVATE (internal implementation)
- init/ Bootstrap logic (aipass init)
- json/ JSON file I/O and validation
[green]\u2713[/green] Rich library = Underlying formatting engine
@@ -252,7 +246,7 @@ def print_help() -> None:
CONSOLE.print()
# Drone compliance — commands line
CONSOLE.print("[dim]Commands: aipass, display, templates, demo, --help[/dim]")
CONSOLE.print("[dim]Commands: display, templates, demo, --help[/dim]")
CONSOLE.print()
@@ -1,519 +0,0 @@
# =================== AIPass ====================
# Name: bootstrap.py
# Description: Init handler — bootstrap an AIPass project in any directory
# Version: 2.0.0
# Created: 2026-03-14
# Modified: 2026-04-22
# =============================================
"""
Init Bootstrap Handler - PRIVATE implementation
Business logic for `aipass init`. Creates the project scaffold:
1. {NAME}_REGISTRY.json — project registry with UUID
2. .aipass/aipass_global_prompt.md — global prompt (injected every turn)
3. CLAUDE.md — project prompt (Claude Code reads this)
4. AGENTS.md — Codex equivalent of CLAUDE.md
5. GEMINI.md — Gemini equivalent of CLAUDE.md
6. README.md — getting started guide
7. STATUS.local.md — project status
8. .gitignore — standard AIPass ignores
9. .claude/settings.json — Claude Code hooks configuration
10. src/ — directory where agents live
11. .ai_mail.local/inbox.json — empty project mailbox
Projects are NOT citizens — no .trinity/ directory. Identity lives in the
registry JSON. Init is re-runnable: existing files are skipped, not errors.
RULES:
- Pure Python only (no module/prax/cli imports)
- Returns dict, raises exceptions on errors
- No hardcoded paths
"""
import importlib.util
import json
import logging
import re
import shutil
import uuid
from datetime import date
from pathlib import Path
from aipass.cli.apps.handlers.init import scaffold_content as sc
logger = logging.getLogger(__name__)
ENFORCEMENT_HOOKS = [
"auto_fix_diagnostics.py",
"pre_edit_gate.py",
"subagent_stop_gate.py",
"pre_compact.py",
]
INJECTOR_HOOKS = [
"branch_prompt_loader.py",
"email_notification.py",
"identity_injector.py",
]
HOOKS_TO_SHIP = ENFORCEMENT_HOOKS + INJECTOR_HOOKS
HOOK_EVENTS: dict[str, str] = {
"auto_fix_diagnostics.py": "PostToolUse",
"pre_edit_gate.py": "PreToolUse",
"subagent_stop_gate.py": "Stop",
"pre_compact.py": "PreCompact",
"branch_prompt_loader.py": "UserPromptSubmit",
"email_notification.py": "UserPromptSubmit",
"identity_injector.py": "UserPromptSubmit",
}
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.
Replaces non-alphanumeric characters (except underscore/hyphen) with
underscores and strips leading/trailing underscores.
"""
return re.sub(r"[^A-Z0-9_-]", "_", raw.upper()).strip("_")
def _detect_aipass_home() -> str | None:
"""Detect the AIPass installation root from the aipass package location.
Returns the parent of the src/ directory (the repo root).
Returns None if detection fails.
"""
try:
spec = importlib.util.find_spec("aipass")
if spec and spec.origin:
# aipass/__init__.py lives at src/aipass/__init__.py
# parent = src/aipass/, parent.parent = src/, parent.parent.parent = AIPass root
return str(Path(spec.origin).resolve().parent.parent.parent)
except Exception as exc:
logger.info("AIPASS_HOME detection skipped: %s", exc)
return None
def _claude_settings(aipass_home: str | None = None) -> str:
"""Generate .claude/settings.json — hooks for prompt injection + enforcement.
Wires all AIPass hooks into their respective event types:
- UserPromptSubmit: global/local prompt injection + branch_prompt_loader,
email_notification, identity_injector
- PostToolUse: auto_fix_diagnostics
- PreToolUse: pre_edit_gate
- Stop: subagent_stop_gate
- PreCompact: pre_compact
Args:
aipass_home: Optional AIPass installation root to add as env.AIPASS_HOME.
"""
_local_prompt_cmd = (
'python3 -c "'
"from pathlib import Path; "
"p=next((x/'.aipass'/'aipass_local_prompt.md' "
"for x in [Path.cwd(),*Path.cwd().parents] "
"if (x/'.aipass'/'aipass_local_prompt.md').exists()),None); "
"p and print(p.read_text(encoding='utf-8'),end='')"
'"'
)
event_hooks: dict[str, list] = {}
for hook_name, event in HOOK_EVENTS.items():
entry = {
"matcher": "",
"hooks": [{"type": "command", "command": f"python3 .claude/hooks/{hook_name}"}],
}
event_hooks.setdefault(event, []).append(entry)
prompt_hooks = [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "cat .aipass/aipass_global_prompt.md 2>/dev/null || true",
}
],
},
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": _local_prompt_cmd,
}
],
},
]
event_hooks["UserPromptSubmit"] = prompt_hooks + event_hooks.get("UserPromptSubmit", [])
data: dict = {"hooks": event_hooks}
if aipass_home:
data["env"] = {"AIPASS_HOME": aipass_home}
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
def init_project(target: Path, project_name: str | None = None) -> dict:
"""Initialize an AIPass project in the target directory.
Args:
target: Directory to initialize
project_name: Name for the registry (defaults to directory name)
Returns:
dict with registry_id, registry_file, project_name, target, created_files
Raises:
ValueError: If project name is empty after sanitization
"""
target = target.resolve()
if not target.exists():
target.mkdir(parents=True)
raw_name = project_name or target.name
name = _sanitize_name(raw_name)
if not name:
raise ValueError(f"Cannot derive project name from '{raw_name}'. Pass a project name explicitly.")
registry_id = str(uuid.uuid4())
today = date.today().isoformat()
created = []
aipass_home = _detect_aipass_home()
# 1. Registry (skip if exists — init is re-runnable)
registry_filename = f"{name}_REGISTRY.json"
registry_path = target / registry_filename
if registry_path.exists():
# Read existing registry to get its ID
existing = json.loads(registry_path.read_text(encoding="utf-8"))
registry_id = existing["metadata"]["id"]
else:
registry_data = {
"metadata": {
"id": registry_id,
"name": name,
"version": "1.0.0",
"created": today,
"last_updated": today,
"total_branches": 0,
},
"branches": [],
}
registry_path.write_text(
json.dumps(registry_data, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
created.append(str(registry_path))
# 2. .aipass/
aipass_dir = target / ".aipass"
aipass_dir.mkdir(exist_ok=True)
global_prompt_path = aipass_dir / "aipass_global_prompt.md"
if not global_prompt_path.exists():
global_prompt_path.write_text(
sc.with_source(sc.global_prompt_md(name), global_prompt_path),
encoding="utf-8",
)
created.append(str(global_prompt_path))
# 3. CLAUDE.md
claude_md_path = target / "CLAUDE.md"
if not claude_md_path.exists():
claude_md_path.write_text(
sc.with_source(sc.claude_md(name), claude_md_path),
encoding="utf-8",
)
created.append(str(claude_md_path))
# 4. AGENTS.md (Codex)
agents_md_path = target / "AGENTS.md"
if not agents_md_path.exists():
agents_md_path.write_text(
sc.with_source(sc.agents_md(name), agents_md_path),
encoding="utf-8",
)
created.append(str(agents_md_path))
# 5. GEMINI.md
gemini_md_path = target / "GEMINI.md"
if not gemini_md_path.exists():
gemini_md_path.write_text(
sc.with_source(sc.gemini_md(name), gemini_md_path),
encoding="utf-8",
)
created.append(str(gemini_md_path))
# 6. README.md
readme_md_path = target / "README.md"
if not readme_md_path.exists():
readme_content = sc.readme_md(name).replace("{date}", today)
readme_md_path.write_text(readme_content, encoding="utf-8")
created.append(str(readme_md_path))
# 7. STATUS.local.md
status_md_path = target / "STATUS.local.md"
if not status_md_path.exists():
status_md_path.write_text(
f"# {name}\n\n**State:** New\n**Last update:** {today}\n\n## Current Work\n\n## Known Issues\n- None\n",
encoding="utf-8",
)
created.append(str(status_md_path))
# 8. .gitignore
gitignore_path = target / ".gitignore"
if not gitignore_path.exists():
gitignore_path.write_text(sc.gitignore(), encoding="utf-8")
created.append(str(gitignore_path))
# 9. .claude/settings.json
claude_dir = target / ".claude"
claude_dir.mkdir(exist_ok=True)
settings_path = claude_dir / "settings.json"
if not settings_path.exists():
settings_path.write_text(_claude_settings(aipass_home), encoding="utf-8")
created.append(str(settings_path))
# 9b. .claude/commands/prep.md — /prep session wrap-up slash command
commands_dir = claude_dir / "commands"
commands_dir.mkdir(exist_ok=True)
prep_path = commands_dir / "prep.md"
if not prep_path.exists():
prep_path.write_text(sc.prep_md(), encoding="utf-8")
created.append(str(prep_path))
# 9c. .claude/commands/memo.md — /memo memory update slash command
memo_path = commands_dir / "memo.md"
if not memo_path.exists():
memo_path.write_text(sc.memo_md(), encoding="utf-8")
created.append(str(memo_path))
# 9d. Ship enforcement + injector hooks from AIPass install
if aipass_home:
shipped = _ship_hooks(aipass_home, target)
created.extend(shipped)
# 10. src/ directory (where agents live)
src_dir = target / "src"
if not src_dir.exists():
src_dir.mkdir()
created.append(str(src_dir))
# 12. .ai_mail.local/inbox.json — empty project mailbox
mail_dir = target / ".ai_mail.local"
mail_dir.mkdir(exist_ok=True)
inbox_path = mail_dir / "inbox.json"
if not inbox_path.exists():
inbox_path.write_text(sc.inbox_json(), encoding="utf-8")
created.append(str(inbox_path))
return {
"registry_id": registry_id,
"registry_file": registry_filename,
"project_name": name,
"target": str(target),
"created_files": created,
"aipass_home": aipass_home,
}
def update_project(target: Path) -> dict:
"""Update managed scaffold files in an existing AIPass project.
Overwrites managed prompt and config files with the latest templates while
leaving all user-owned files (registry, README, STATUS.local.md, .gitignore,
src/) untouched.
Args:
target: Directory containing the AIPass project to update.
Returns:
dict with project_name, target, updated_files, skipped_files.
Raises:
ValueError: If no ``*_REGISTRY.json`` is found in target (not an AIPass
project or init has not been run yet).
"""
target = target.resolve()
# Guard: refuse to update the AIPass source repo itself. The source repo
# has hand-maintained production files that must not be overwritten with
# generic templates. External projects created via `aipass init` are fine.
if (target / "src" / "aipass").is_dir() and (target / "pyproject.toml").exists():
raise ValueError(
"Cannot update the AIPass source repository — its files are hand-maintained, not template-generated"
)
# Locate the project registry to confirm this is an AIPass project and
# derive the project name without parsing JSON (filename encodes the name).
registry_files = list(target.glob("*_REGISTRY.json"))
if not registry_files:
raise ValueError("No AIPass project found — run 'aipass init' first")
registry_path = registry_files[0]
name = registry_path.stem.replace("_REGISTRY", "")
updated: list[str] = []
already_current: list[str] = []
skipped: list[str] = []
aipass_home: str | None = None
# Managed directories — create if missing (graceful recovery).
aipass_dir = target / ".aipass"
aipass_dir.mkdir(exist_ok=True)
claude_dir = target / ".claude"
claude_dir.mkdir(exist_ok=True)
# --- Managed files: write only when content has changed ---
global_prompt_path = aipass_dir / "aipass_global_prompt.md"
generated = sc.with_source(sc.global_prompt_md(name), global_prompt_path)
if not global_prompt_path.exists() or global_prompt_path.read_text(encoding="utf-8") != generated:
global_prompt_path.write_text(generated, encoding="utf-8")
updated.append(str(global_prompt_path))
else:
already_current.append(str(global_prompt_path))
# settings.json — smart merge: preserve existing AIPASS_HOME, detect if missing
settings_path = claude_dir / "settings.json"
if not settings_path.exists():
aipass_home = _detect_aipass_home()
settings_path.write_text(_claude_settings(aipass_home), encoding="utf-8")
updated.append(str(settings_path))
else:
existing_content = settings_path.read_text(encoding="utf-8")
try:
existing_env = json.loads(existing_content).get("env", {})
except json.JSONDecodeError as exc:
logger.info("settings.json parse failed, rebuilding: %s", exc)
existing_env = {}
# Preserve existing AIPASS_HOME; detect and add if missing
aipass_home = existing_env.get("AIPASS_HOME") or _detect_aipass_home()
generated = _claude_settings(aipass_home)
if existing_content != generated:
settings_path.write_text(generated, encoding="utf-8")
updated.append(str(settings_path))
else:
already_current.append(str(settings_path))
claude_md_path = target / "CLAUDE.md"
generated = sc.with_source(sc.claude_md(name), claude_md_path)
if not claude_md_path.exists() or claude_md_path.read_text(encoding="utf-8") != generated:
claude_md_path.write_text(generated, encoding="utf-8")
updated.append(str(claude_md_path))
else:
already_current.append(str(claude_md_path))
agents_md_path = target / "AGENTS.md"
generated = sc.with_source(sc.agents_md(name), agents_md_path)
if not agents_md_path.exists() or agents_md_path.read_text(encoding="utf-8") != generated:
agents_md_path.write_text(generated, encoding="utf-8")
updated.append(str(agents_md_path))
else:
already_current.append(str(agents_md_path))
gemini_md_path = target / "GEMINI.md"
generated = sc.with_source(sc.gemini_md(name), gemini_md_path)
if not gemini_md_path.exists() or gemini_md_path.read_text(encoding="utf-8") != generated:
gemini_md_path.write_text(generated, encoding="utf-8")
updated.append(str(gemini_md_path))
else:
already_current.append(str(gemini_md_path))
# .claude/commands/prep.md — managed slash command, refresh to latest
commands_dir = claude_dir / "commands"
commands_dir.mkdir(exist_ok=True)
prep_path = commands_dir / "prep.md"
generated = sc.prep_md()
if not prep_path.exists() or prep_path.read_text(encoding="utf-8") != generated:
prep_path.write_text(generated, encoding="utf-8")
updated.append(str(prep_path))
else:
already_current.append(str(prep_path))
# .claude/commands/memo.md — managed slash command, refresh to latest
memo_path = commands_dir / "memo.md"
generated = sc.memo_md()
if not memo_path.exists() or memo_path.read_text(encoding="utf-8") != generated:
memo_path.write_text(generated, encoding="utf-8")
updated.append(str(memo_path))
else:
already_current.append(str(memo_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),
str(target / "README.md"),
str(target / "STATUS.local.md"),
str(target / ".gitignore"),
):
skipped.append(skip_name)
# Mailbox — create if missing, never overwrite existing
mail_dir = target / ".ai_mail.local"
mail_dir.mkdir(exist_ok=True)
inbox_path = mail_dir / "inbox.json"
if not inbox_path.exists():
inbox_path.write_text(sc.inbox_json(), encoding="utf-8")
updated.append(str(inbox_path))
else:
skipped.append(str(inbox_path))
return {
"project_name": name,
"target": str(target),
"updated_files": updated,
"already_current": already_current,
"skipped_files": skipped,
"aipass_home": aipass_home,
}
@@ -1,512 +0,0 @@
# =================== AIPass ====================
# Name: scaffold_content.py
# Description: Template content generators for aipass init scaffold
# Version: 1.0.0
# Created: 2026-04-22
# Modified: 2026-04-22
# =============================================
"""
Scaffold Content — template generators for `aipass init`
Pure string-returning functions that produce the content for each scaffold
file (CLAUDE.md, AGENTS.md, etc.). Extracted from bootstrap.py to keep
the handler under 700 lines.
RULES:
- Pure Python only (no module/prax/cli imports)
- Each function returns a string
- No side effects
"""
import json
from pathlib import Path
def claude_md(name: str) -> str:
"""Generate CLAUDE.md content — Claude Code reads this on startup."""
return (
f"# {name}\n"
"\n"
"**User:** (your name here)\n"
"\n"
"## What is AIPass\n"
"\n"
"AIPass is a multi-agent framework. This project was created with `aipass init`.\n"
"\n"
"**Key concepts:**\n"
"- **Project** — this directory. Contains a registry and one or more agents.\n"
"- **Agent** — a citizen that lives inside the project. Has identity (`.trinity/`), "
"memory, mailbox, and its own apps/ directory.\n"
f"- **Registry** — `{name}_REGISTRY.json` tracks all agents in this project.\n"
"\n"
"## Getting Started\n"
"\n"
"Create your first agent:\n"
"```\n"
"aipass init agent <name>\n"
"```\n"
"\n"
"This creates a full agent scaffold inside `src/<name>/` "
"(`apps/`, `.trinity/`, `.ai_mail.local/`) "
"and registers it in your project registry.\n"
"\n"
"## Available Commands\n"
"\n"
"```\n"
"aipass init agent <name> # Create a new agent\n"
"drone @spawn create <name> # Create agent (alternative)\n"
"drone @seedgo audit <project> # Run standards audit\n"
"drone @ai_mail inbox # Check mailbox (per-agent)\n"
"drone systems # List all available infrastructure\n"
"```\n"
"\n"
"## Startup Protocol\n"
"\n"
"On any greeting, silently read these files — no narration, just do it "
"and respond with the status.\n"
"\n"
f"**Read:** `{name}_REGISTRY.json`, `README.md`, `STATUS.local.md`\n"
"**Run:** `git status`\n"
"\n"
"Then check the registry for agents and report status.\n"
)
def agents_md(name: str) -> str:
"""Generate AGENTS.md content — Codex equivalent of CLAUDE.md."""
return (
f"# {name} — Agent Instructions\n"
"\n"
"This project uses AIPass, a multi-agent framework.\n"
"\n"
"## Key Concepts\n"
"\n"
"- **Project** — this directory. Contains a registry and one or more agents.\n"
"- **Agent** — a citizen that lives inside the project with its own identity, "
"memory, and code.\n"
f"- **Registry** — `{name}_REGISTRY.json` tracks all agents.\n"
"\n"
"## Getting Started\n"
"\n"
"Create your first agent:\n"
"```\n"
"aipass init agent <name>\n"
"```\n"
"\n"
"## Available Commands\n"
"\n"
"```\n"
"aipass init agent <name> # Create a new agent\n"
"drone @spawn create <name> # Create agent (alternative)\n"
"drone @seedgo audit <project> # Run standards audit\n"
"drone systems # List all infrastructure\n"
"```\n"
"\n"
"## Startup\n"
"\n"
f"On startup, read: `{name}_REGISTRY.json`, `README.md`, `STATUS.local.md`\n"
)
def gemini_md(name: str) -> str:
"""Generate GEMINI.md content — Gemini equivalent of CLAUDE.md."""
return (
f"# {name} — Project Instructions\n"
"\n"
"This project uses AIPass, a multi-agent framework.\n"
"\n"
"## Key Concepts\n"
"\n"
"- **Project** — this directory. Contains a registry and one or more agents.\n"
"- **Agent** — a citizen that lives inside the project with its own identity, "
"memory, and code.\n"
f"- **Registry** — `{name}_REGISTRY.json` tracks all agents.\n"
"\n"
"## Getting Started\n"
"\n"
"Create your first agent: `aipass init agent <name>`\n"
"\n"
"## Available Commands\n"
"\n"
"```\n"
"aipass init agent <name> # Create a new agent\n"
"drone @spawn create <name> # Create agent (alternative)\n"
"drone @seedgo audit <project> # Run standards audit\n"
"drone systems # List all infrastructure\n"
"```\n"
"\n"
"## Startup\n"
"\n"
f"On startup, read: `{name}_REGISTRY.json`, `README.md`, `STATUS.local.md`\n"
)
def readme_md(name: str) -> str:
"""Generate README.md content — real getting started guide."""
return (
f"# {name}\n"
"\n"
"An AIPass project.\n"
"\n"
"## Quick Start\n"
"\n"
"```bash\n"
"# 1. Create your first agent\n"
"aipass init agent my_agent\n"
"\n"
"# 2. Start a session\n"
"cd src/my_agent/\n"
"claude # or your preferred AI CLI\n"
"\n"
"# 3. Check project status\n"
"cat STATUS.local.md\n"
"```\n"
"\n"
"## Project Structure\n"
"\n"
"```\n"
f"{name.lower()}/\n"
f" {name}_REGISTRY.json # Agent registry\n"
" .aipass/ # Prompts (injected per-turn)\n"
" CLAUDE.md # Claude Code instructions\n"
" AGENTS.md # Codex instructions\n"
" GEMINI.md # Gemini instructions\n"
" STATUS.local.md # Project status\n"
" src/ # Agent directories live here\n"
" <agent_name>/ # Created via aipass init agent\n"
"```\n"
"\n"
"## What is AIPass?\n"
"\n"
"AIPass is a multi-agent framework where autonomous agents (citizens) "
"live in directories with persistent identity, memory, and communication.\n"
"\n"
"Each agent has:\n"
"- **Identity** — `.trinity/passport.json`\n"
"- **Memory** — `.trinity/local.json`, `observations.json`\n"
"- **Mailbox** — `.ai_mail.local/`\n"
"- **Code** — `apps/` with modules and handlers\n"
"\n"
"## Commands\n"
"\n"
"| Command | Description |\n"
"|---------|-------------|\n"
"| `aipass init agent <name>` | Create a new agent |\n"
"| `drone @spawn create <name>` | Create agent (alternative) |\n"
"| `drone @seedgo audit <project>` | Run standards audit |\n"
"| `drone @ai_mail inbox` | Check agent mailbox |\n"
"| `drone systems` | List infrastructure |\n"
"\n"
f"*Initialized with [AIPass](https://github.com/AIOSAI/AIPass) on "
f"{{date}}*\n"
)
def global_prompt_md(name: str) -> str:
"""Generate .aipass/aipass_global_prompt.md — injected every turn."""
return (
f"# {name} — Project Context\n"
"<!-- Injected every turn via hook. -->\n"
"\n"
"## What is AIPass\n"
"\n"
"AIPass is a multi-agent framework. Agents live in directories with\n"
"persistent identity, memory, and communication. All AIPass infrastructure\n"
"is available from any project via the `drone` command.\n"
"\n"
"## Terminology\n"
"\n"
"- **Project** — this directory. Contains a registry and agents.\n"
"- **Agent** — a citizen with identity (`.trinity/`), memory, mailbox,\n"
" and code (`apps/`).\n"
f"- **Registry** — `{name}_REGISTRY.json` tracks all agents.\n"
"\n"
"## Setup: if drone commands fail\n"
"\n"
"If `drone` cannot find the AIPass registry, set the env var:\n"
"```bash\n"
"export AIPASS_HOME=/path/to/AIPass # path to AIPass installation\n"
"```\n"
"Add to your shell profile (`~/.bashrc` or `~/.zshrc`) to make it permanent.\n"
"\n"
"## Commands\n"
"\n"
"### Agent Lifecycle\n"
"```\n"
"aipass init agent <name> # Create a new agent in src/<name>/\n"
"drone @spawn create <name> # Create agent (alternative)\n"
"drone @spawn list # List registered agents\n"
"```\n"
"\n"
"### Standards\n"
"```\n"
"drone @seedgo audit <project> # Run full standards audit\n"
"drone @seedgo checklist <file> # Check a single file\n"
"```\n"
"\n"
"### Dispatch — Send Task + Wake an Agent (DEFAULT)\n"
"```\n"
'drone @ai_mail dispatch @<agent> "Subject" "Body" # Send + wake (default)\n'
'drone @ai_mail dispatch @<agent> "Subject" "Body" --fresh # Send + wake fresh session\n'
"drone @ai_mail dispatch wake @<agent> # Wake without sending\n"
"drone @ai_mail dispatch wake --fresh @<agent> # Wake fresh\n"
'drone @ai_mail email @<agent> "Subject" "Body" # FYI only (no wake)\n'
"```\n"
"\n"
"Use `dispatch` by default. Use `email` only when you don't need the agent to act now.\n"
"\n"
"### Communication (ai_mail)\n"
"```\n"
"drone @ai_mail inbox # Check your mailbox\n"
"drone @ai_mail view <id> # Read a message\n"
"drone @ai_mail close <id> # Mark message read\n"
"```\n"
"\n"
"### Feedback\n"
"```\n"
'drone @devpulse feedback send "Subject" "Body" # Send feedback (cross-project)\n'
"```\n"
"\n"
"### Plans (flow)\n"
"```\n"
'drone @flow create . "Subject" dplan # Create DPLAN (design/thinking)\n'
'drone @flow create . "Subject" master # Create FPLAN master (execution)\n'
'drone @flow create . "Subject" aplan # Create APLAN (agent-level task)\n'
"drone @flow list open # List active plans\n"
"drone @flow list # List all plans\n"
"drone @flow close <id> # Close a plan\n"
"drone @flow info <id> # View plan details\n"
"```\n"
"\n"
"**DPLAN** = Dev Plan. Thinking, brainstorming, architecture decisions. "
"Use before building.\n"
"**FPLAN** = Flow Plan. Building and executing. Use when the plan is clear "
"and work is underway.\n"
"\n"
"### Memory\n"
"```\n"
"drone @memory archive # Archive memories to vector store\n"
"drone @memory search <query> # Search archived memories\n"
"```\n"
"\n"
"### Git Workflow\n"
"```\n"
"drone @git pr 'description' # Create a pull request\n"
"drone @git status # Git status (branch-scoped)\n"
"drone @git sync # Sync with main\n"
"drone @git lock / unlock # Lock/unlock the repo\n"
"```\n"
"\n"
"### Infrastructure\n"
"```\n"
"drone systems # List all available infrastructure\n"
"drone --help # Full drone command reference\n"
"```\n"
"\n"
"## Patterns\n"
"\n"
"- **Communication** — agents communicate via `.ai_mail.local/`.\n"
"- **Standards** — run `drone @seedgo audit` to check compliance.\n"
"- **Identity** — agents have `.trinity/passport.json`. "
"Projects use the registry.\n"
"- **Memory** — update `.trinity/local.json` at session end. "
"Memory is presence.\n"
"\n"
"## Maintenance\n"
"\n"
"- **Upgrade scaffold**: `drone @cli aipass init update` refreshes "
"managed project files (hooks, prompts, settings) to latest templates.\n"
"- **Entry point**: each agent's `apps/{name}.py` auto-configures "
"`sys.path` and `AIPASS_BRANCH_NAME` env var. If prax logs to "
"`unknown_branch/`, check that these are set.\n"
"- **Standalone projects** use `src/{name}/` layout (not `src/aipass/{name}/`). "
"Module discovery adapts automatically.\n"
)
def gitignore() -> str:
"""Generate .gitignore content — standard AIPass ignores."""
return (
"# AIPass local state\n"
".trinity/\n"
".ai_mail.local/\n"
"*.local.*\n"
"!STATUS.local.md\n"
"\n"
"# Plans (local working docs)\n"
"DPLAN-*\n"
"FPLAN-*\n"
"APLAN-*\n"
"TDPLAN-*\n"
"\n"
"# Logs\n"
"logs/\n"
"\n"
"# Python\n"
"__pycache__/\n"
"*.py[cod]\n"
"*.egg-info/\n"
"dist/\n"
"build/\n"
".venv/\n"
"venv/\n"
"\n"
"# IDE\n"
".vscode/\n"
".idea/\n"
"\n"
"# OS\n"
".DS_Store\n"
"Thumbs.db\n"
"\n"
"# Archives\n"
".archive/\n"
"\n"
"# Disabled files\n"
"*(disabled)*\n"
)
def prep_md() -> str:
"""Generate .claude/commands/prep.md — /prep session wrap-up slash command."""
return (
"# Session Wrap-Up\n"
"\n"
"Purpose: Button up everything at the end of a session — or before a "
"/compact. Memories, plans, git — all tidy. Works for both closing out "
"a chat and preparing for compaction.\n"
"\n"
"**Workflow:** `/prep` → review output → close chat or `/compact`\n"
"\n"
"## Execution\n"
"\n"
"1. Read `.trinity/passport.json` first — re-absorb your identity "
"before writing anything\n"
"2. Do ALL of the following, then confirm what was updated\n"
"\n"
"## 1. Memories\n"
"\n"
"Each memory file plays a distinct role. Update based on what actually "
"changed this session.\n"
"\n"
"- **`.trinity/passport.json`** — IDENTITY. Who you are: role, "
"capabilities, principles. Only update if identity genuinely evolved "
"this session.\n"
"- **`.trinity/local.json`** — YOUR MEMORY. Add/update session entry "
"with a summary of work done. Add key_learnings for anything learned. "
"Trim oldest sessions if over 20.\n"
"- **`.trinity/observations.json`** — YOUR MEMORY OF THE USER. "
"Collaboration insights, preferences, friction points. Skip if nothing "
"new about the user this session.\n"
"- **`STATUS.local.md`** — PUBLIC STATUS BEACON. Current work, known "
"issues, todos, notepad. Auto-synced to central STATUS.md on PR events "
"— this is how other branches see you. Keep Current Work accurate.\n"
"\n"
"## 2. Active Plans\n"
"\n"
"- Check any DPLANs or FPLANs referenced in this session\n"
"- Update their execution logs, status, decision logs with current "
"state\n"
"- If a plan was completed, note it (but don't close — the user does "
"that)\n"
"\n"
"## 3. Git State\n"
"\n"
"- Run `git status` — report uncommitted changes\n"
"- If there's a logical commit waiting, suggest it (don't commit "
"without asking)\n"
"- Note the current branch and any open PRs\n"
"\n"
"## 4. Inbox\n"
"\n"
"- Run `drone @ai_mail inbox 2>/dev/null` — report any unread emails\n"
"- Close any that were already processed but not formally closed\n"
"\n"
"## 5. Loose Ends\n"
"\n"
"- Flag anything in-flight: running background agents, dispatched "
"branches waiting for replies, pending decisions\n"
"- If anything can't survive compaction (e.g., agent IDs needed for "
"resume), write it to STATUS.local.md Notepad\n"
"\n"
"## Confirm\n"
"\n"
"List everything updated. Format:\n"
"```\n"
"Prep complete:\n"
"- local.json: [what was added]\n"
"- observations.json: [updated / skipped]\n"
"- STATUS.local.md: [updated / skipped]\n"
"- Plans: [which ones updated]\n"
"- Git: [branch, uncommitted count, suggestion]\n"
"- Inbox: [count, action taken]\n"
"- Loose ends: [any flagged]\n"
"\n"
"Ready to close out or /compact.\n"
"```\n"
)
def memo_md() -> str:
"""Generate .claude/commands/memo.md — /memo memory update slash command."""
return (
"# Memory Update\n"
"\n"
"Purpose: Update branch memory files after completing work this session.\n"
"\n"
"## Execution\n"
"\n"
"1. Read `.trinity/passport.json` first — re-absorb your identity, "
"role, and principles before writing memories\n"
"2. Review what was done this session (context, recent changes, key "
"decisions)\n"
"3. Update each file below as needed\n"
"4. Confirm completion — list files updated\n"
"\n"
"## Memory Roles\n"
"\n"
"Each memory file plays a distinct role. Update based on what actually "
"changed this session.\n"
"\n"
"- **`.trinity/passport.json`** — IDENTITY. Who you are: role, "
"capabilities, principles. Only update if identity genuinely evolved "
"this session. Don't touch it just to touch it.\n"
"- **`.trinity/local.json`** — YOUR MEMORY. Session history and "
"key_learnings. Add a session entry for significant work. Add "
"key_learnings for facts you'd need next time. Trim oldest sessions "
"if over 20.\n"
"- **`.trinity/observations.json`** — YOUR MEMORY OF THE USER. "
"Collaboration insights, preferences, friction points, flow states. "
"Skip entirely if nothing new about the user this session.\n"
"- **`STATUS.local.md`** — PUBLIC STATUS BEACON. Current work, known "
"issues, todos, notepad. Auto-synced to central STATUS.md on PR "
"events — this is how other branches see you. Keep Current Work "
"accurate and drop quick notes in the Notepad section.\n"
"\n"
"## If Relevant\n"
"\n"
"- **README.md** — Does it reflect current state? Update if stale.\n"
)
def inbox_json() -> str:
"""Generate .ai_mail.local/inbox.json — empty project mailbox structure."""
return (
json.dumps(
{
"mailbox": "inbox",
"total_messages": 0,
"unread_count": 0,
"messages": [],
},
indent=2,
ensure_ascii=False,
)
+ "\n"
)
def with_source(content: str, file_path: Path) -> str:
"""Prepend a source header to AI prompt file content."""
return f"<!-- Source: {file_path} -->\n{content}"
-547
View File
@@ -1,547 +0,0 @@
# =================== AIPass ====================
# Name: init_project.py
# Description: AIPass Project Commands Module — owns the 'aipass' top-level command
# Version: 2.0.0
# Created: 2026-03-14
# Modified: 2026-03-15
# =============================================
"""
AIPass Project Commands Module
Owns the 'aipass' top-level command and routes subcommands (init, etc.).
Follows seedgo module interface: handle_command(), print_introspection(), print_help().
Run: drone @cli aipass
"""
import os
import subprocess
import sys
from pathlib import Path
from typing import List
from aipass.cli.apps.handlers.init.bootstrap import init_project, update_project
from aipass.cli.apps.modules.display import console, success, error, header
from aipass.cli.apps.handlers.json import json_handler
from aipass.prax.apps.modules.logger import system_logger as logger
# =============================================================================
# MODULE PATTERN FUNCTIONS (SEEDGO compliant)
# =============================================================================
def print_introspection():
"""Display aipass command info — available subcommands and connected handlers."""
from rich.table import Table
console.print()
console.print("[bold cyan]aipass — Project Commands[/bold cyan]")
console.print("[dim]Manage AIPass projects from the command line[/dim]")
console.print()
# Available subcommands
table = Table(show_header=True, header_style="bold cyan", border_style="dim")
table.add_column("Command", style="green")
table.add_column("Description", style="white")
table.add_column("Example", style="dim")
table.add_row(
"init",
"Bootstrap a new AIPass project",
"drone @cli aipass init /path MyProject",
)
table.add_row(
"init agent",
"Create an agent in current project",
"drone @cli aipass init agent my_agent",
)
table.add_row(
"init update",
"Refresh managed scaffold files",
"drone @cli aipass init update",
)
console.print(table)
console.print()
console.print("[yellow]Connected Handlers:[/yellow]")
console.print(" [cyan]handlers/init/[/cyan]")
console.print(" [dim]- bootstrap.py[/dim]")
console.print()
console.print("[yellow]Next:[/yellow] Run a subcommand")
console.print(" [green]drone @cli aipass init[/green] [dim]# Bootstrap a project[/dim]")
console.print(" [green]drone @cli aipass init --help[/green] [dim]# Detailed init usage[/dim]")
console.print(" [green]drone @cli aipass --help[/green] [dim]# Full help[/dim]")
console.print()
def print_help():
"""Display Rich-formatted help for the aipass command and all subcommands."""
from rich.panel import Panel
from rich.table import Table
from rich import box
console.print()
header("aipass — Project Commands")
console.print("[dim]Manage AIPass projects from the command line[/dim]")
console.print()
console.print("─" * 70)
console.print()
# Subcommands
console.print("[bold cyan]COMMANDS:[/bold cyan]")
console.print()
console.print(" [green]drone @cli aipass[/green] [dim]Show available subcommands[/dim]")
console.print(" [green]drone @cli aipass init[/green] [dim]Bootstrap in current directory[/dim]")
console.print(" [green]drone @cli aipass init /path[/green] [dim]Bootstrap in target directory[/dim]")
console.print(" [green]drone @cli aipass init /path MyProj[/green] [dim]Bootstrap with custom name[/dim]")
console.print(
" [green]drone @cli aipass init agent <name>[/green] [dim]Create an agent in current project[/dim]"
)
console.print(" [green]drone @cli aipass init update[/green] [dim]Refresh managed scaffold files[/dim]")
console.print(" [green]drone @cli aipass --help[/green] [dim]This help message[/dim]")
console.print()
console.print("─" * 70)
console.print()
# What init creates
console.print("[bold cyan]WHAT INIT CREATES:[/bold cyan]")
console.print()
files_text = """[bold]Project scaffold (12 items):[/bold]
[green]1.[/green] [yellow]{NAME}_REGISTRY.json[/yellow] Project registry with UUID
[green]2.[/green] [yellow].aipass/aipass_global_prompt.md[/yellow] Global prompt (injected every turn)
[green]3.[/green] [yellow]CLAUDE.md[/yellow] Project prompt (Claude Code)
[green]4.[/green] [yellow]AGENTS.md[/yellow] Codex instructions
[green]5.[/green] [yellow]GEMINI.md[/yellow] Gemini instructions
[green]6.[/green] [yellow]README.md[/yellow] Getting started guide
[green]7.[/green] [yellow]STATUS.local.md[/yellow] Project status
[green]8.[/green] [yellow].gitignore[/yellow] Standard AIPass ignores
[green]9.[/green] [yellow].claude/settings.json[/yellow] Claude Code hooks + AIPASS_HOME
[green]10.[/green] [yellow]src/[/yellow] Agent directories live here
[green]11.[/green] [yellow].ai_mail.local/inbox.json[/yellow] Empty project mailbox"""
console.print(Panel(files_text, border_style="green", padding=(1, 2), box=box.ROUNDED))
console.print()
console.print("─" * 70)
console.print()
# Arguments
console.print("[bold cyan]ARGUMENTS (init):[/bold cyan]")
console.print()
args_table = Table(show_header=True, header_style="bold cyan", border_style="dim")
args_table.add_column("Argument", style="green")
args_table.add_column("Required", style="yellow")
args_table.add_column("Default", style="dim")
args_table.add_column("Description", style="white")
args_table.add_row("target_dir", "No", "Current directory", "Directory to initialize")
args_table.add_row("project_name", "No", "Directory name", "Name for registry (auto-uppercased)")
console.print(args_table)
console.print()
console.print("─" * 70)
console.print()
console.print("[dim]Commands: aipass, aipass init, aipass --help[/dim]")
console.print()
def handle_command(command: str, args: List[str]) -> bool:
"""Handle 'aipass' command with subcommand routing.
Args:
command: The command string (e.g. "aipass")
args: Remaining arguments after the command
[] -> show introspection (available subcommands)
["init"] -> run init
["init", "--help"] -> show init help
["--help"] -> show full help
Returns:
True if handled, False if not this module's command
"""
# Direct subcommand: 'aipass init /path' on PATH → command="init"
if command == "init":
return _handle_init(args)
# Direct subcommand shortcut: command="update" → treated as 'init update'
if command == "update":
return _handle_init(["update"] + args)
# Prefixed command: 'drone @cli aipass init' → command="aipass"
if command != "aipass":
return False
# No subcommand -> show introspection
if not args:
print_introspection()
return True
# Help flag -> show full help
if args[0] in ("--help", "-h", "help"):
print_help()
return True
subcmd = args[0]
sub_args = args[1:]
# Route subcommands
if subcmd == "init":
return _handle_init(sub_args)
error(f"Unknown aipass subcommand: {subcmd}", suggestion="drone @cli aipass --help")
return True
def _handle_init(args: List[str]) -> bool:
"""Handle the 'init' subcommand."""
from rich.panel import Panel
from rich.table import Table
from rich import box
# Handle help flag
if args and args[0] in ("--help", "-h", "help"):
_print_init_help()
return True
# Route 'init update' to update handler
if args and args[0] == "update":
return _handle_init_update(args[1:])
# Route 'init agent <name>' to spawn
if args and args[0] == "agent":
return _handle_init_agent(args[1:])
# Parse positional args: [target_dir] [project_name]
caller_cwd = os.environ.get("AIPASS_CALLER_CWD", os.getcwd())
if args:
target = Path(args[0])
if not target.is_absolute():
target = Path(caller_cwd) / target
else:
target = Path(caller_cwd)
project_name = args[1] if len(args) > 1 else None
try:
result = init_project(target, project_name)
except ValueError as exc:
logger.warning("Init validation error: %s", exc)
error(str(exc), suggestion="Pass a project name explicitly")
sys.exit(1)
except OSError as exc:
logger.error("Init filesystem error: %s", exc)
error(f"Filesystem error: {exc}")
sys.exit(1)
# Display results
console.print()
header("Project Initialized")
# Summary panel
summary = (
f"[bold]{result['project_name']}[/bold]\n"
f"\n"
f" [yellow]Registry:[/yellow] {result['registry_file']}\n"
f" [yellow]ID:[/yellow] [dim]{result['registry_id'][:8]}...[/dim]\n"
f" [yellow]Target:[/yellow] [dim]{result['target']}[/dim]"
)
console.print(Panel(summary, border_style="green", box=box.ROUNDED))
# Files table
files_table = Table(show_header=True, header_style="bold cyan", border_style="dim")
files_table.add_column("#", style="green", width=3)
files_table.add_column("File", style="yellow")
for i, f in enumerate(result["created_files"], 1):
files_table.add_row(str(i), f)
console.print(files_table)
console.print()
success(f"Created {len(result['created_files'])} files")
if result.get("aipass_home"):
console.print()
console.print(f"[bold cyan]AIPASS_HOME:[/bold cyan] [yellow]{result['aipass_home']}[/yellow]")
console.print("[dim]For terminal usage, add to your shell profile:[/dim]")
console.print(f" [green]export AIPASS_HOME={result['aipass_home']}[/green]")
json_handler.log_operation(
"aipass_init",
{
"project_name": result["project_name"],
"target": result["target"],
"files_created": len(result["created_files"]),
},
)
# Next steps
console.print()
console.print("[bold cyan]Next steps:[/bold cyan]")
console.print(" [green]1.[/green] Create your first agent: [yellow]aipass init agent <name>[/yellow]")
console.print(" [green]2.[/green] Start a session: [dim]cd src/<name>/ && claude[/dim]")
console.print(" [green]3.[/green] Read the docs: [dim]cat README.md[/dim]")
console.print()
return True
def _handle_init_agent(args: List[str]) -> bool:
"""Handle 'aipass init agent <name>' — routes to drone @spawn create."""
if not args or args[0] in ("--help", "-h", "help"):
console.print()
header("aipass init agent — Create an Agent")
console.print("[dim]Creates a new agent inside the current project[/dim]")
console.print()
console.print("[bold cyan]Usage:[/bold cyan]")
console.print(" [green]aipass init agent <name>[/green]")
console.print(" [green]drone @cli aipass init agent <name>[/green]")
console.print()
console.print("[bold cyan]What it does:[/bold cyan]")
console.print(" Routes to [yellow]drone @spawn create src/<name>[/yellow]")
console.print(" Creates full agent scaffold in src/<name>/ (apps/, .trinity/, .ai_mail.local/)")
console.print(" Registers agent in the project registry")
console.print()
console.print("[dim]Commands: init agent, init agent --help[/dim]")
console.print()
return True
agent_name = args[0]
agent_path = f"src/{agent_name}"
extra_flags = args[1:]
logger.info("Routing 'init agent %s' to drone @spawn create %s", agent_name, agent_path)
try:
result = subprocess.run(
["drone", "@spawn", "create", agent_path] + extra_flags,
check=False,
)
if result.returncode != 0:
error(
f"Agent creation failed (exit {result.returncode})",
suggestion="Check drone @spawn create --help",
)
return True
except FileNotFoundError:
logger.warning("drone command not found on PATH")
error(
"drone command not found",
suggestion="Ensure AIPass is installed and drone is in PATH",
)
return True
def _handle_init_update(args: List[str]) -> bool:
"""Handle 'aipass init update' — refresh managed scaffold files."""
from rich.panel import Panel
from rich.table import Table
from rich import box
if args and args[0] in ("--help", "-h", "help"):
console.print()
header("aipass init update — Refresh Scaffold Files")
console.print("[dim]Updates managed prompt and config files with the latest templates[/dim]")
console.print()
console.print("[bold cyan]Usage:[/bold cyan]")
console.print(" [green]drone @cli aipass init update[/green] [dim]Update current directory[/dim]")
console.print(" [green]drone @cli aipass init update /path/to/dir[/green] [dim]Update target directory[/dim]")
console.print()
console.print("[bold cyan]What gets updated:[/bold cyan]")
console.print(" .aipass/aipass_global_prompt.md, .claude/settings.json")
console.print(" CLAUDE.md, AGENTS.md, GEMINI.md")
console.print()
console.print("[bold cyan]What is never touched:[/bold cyan]")
console.print(" *_REGISTRY.json, README.md, STATUS.local.md, .gitignore, src/")
console.print()
console.print("[dim]Commands: init update, init update --help[/dim]")
console.print()
return True
caller_cwd = os.environ.get("AIPASS_CALLER_CWD", os.getcwd())
target = Path(args[0]) if args else Path(caller_cwd)
if not target.is_absolute():
target = Path(caller_cwd) / target
try:
result = update_project(target)
except ValueError as exc:
logger.warning("Init update validation error: %s", exc)
error(str(exc), suggestion="Run 'aipass init' first to create the project")
sys.exit(1)
except OSError as exc:
logger.error("Init update filesystem error: %s", exc)
error(f"Filesystem error: {exc}")
sys.exit(1)
# Display results
console.print()
header("Project Updated")
already_current = result.get("already_current", [])
summary = (
f"[bold]{result['project_name']}[/bold]\n"
f"\n"
f" [yellow]Target:[/yellow] [dim]{result['target']}[/dim]\n"
f" [yellow]Updated:[/yellow] {len(result['updated_files'])} files\n"
f" [yellow]Already current:[/yellow] {len(already_current)} files\n"
f" [yellow]User-owned:[/yellow] {len(result['skipped_files'])} files (skipped)"
)
console.print(Panel(summary, border_style="green", box=box.ROUNDED))
# Updated files table (only show if something changed)
if result["updated_files"]:
updated_table = Table(show_header=True, header_style="bold cyan", border_style="dim", title="Updated")
updated_table.add_column("#", style="green", width=3)
updated_table.add_column("File", style="yellow")
for i, f in enumerate(result["updated_files"], 1):
updated_table.add_row(str(i), f)
console.print(updated_table)
# Already current table
if already_current:
current_table = Table(show_header=True, header_style="bold cyan", border_style="dim", title="Already current")
current_table.add_column("#", style="dim", width=3)
current_table.add_column("File", style="dim")
for i, f in enumerate(already_current, 1):
current_table.add_row(str(i), f)
console.print(current_table)
# Skipped files table
skipped_table = Table(show_header=True, header_style="bold cyan", border_style="dim", title="User-owned (skipped)")
skipped_table.add_column("#", style="dim", width=3)
skipped_table.add_column("File", style="dim")
for i, f in enumerate(result["skipped_files"], 1):
skipped_table.add_row(str(i), f)
console.print(skipped_table)
console.print()
if result["updated_files"]:
success(f"Updated {len(result['updated_files'])} files")
else:
success("All files already up to date")
json_handler.log_operation(
"aipass_init_update",
{
"project_name": result["project_name"],
"target": result["target"],
"files_updated": len(result["updated_files"]),
"files_already_current": len(already_current),
"files_skipped": len(result["skipped_files"]),
},
)
return True
def _print_init_help():
"""Display detailed help for the init subcommand."""
from rich.panel import Panel
from rich.table import Table
from rich import box
console.print()
header("aipass init — Project Bootstrap")
console.print("[dim]Initialize an AIPass project in any directory[/dim]")
console.print()
console.print("─" * 70)
console.print()
# Usage examples
console.print("[bold cyan]USAGE:[/bold cyan]")
console.print()
usage_table = Table(show_header=False, border_style="dim", box=box.SIMPLE, padding=(0, 2))
usage_table.add_column("Command", style="yellow")
usage_table.add_column("Description", style="white")
usage_table.add_row("drone @cli aipass init", "Initialize in current directory")
usage_table.add_row("drone @cli aipass init /path/to/dir", "Initialize in target directory")
usage_table.add_row("drone @cli aipass init /path MyProj", "Initialize with custom project name")
usage_table.add_row("drone @cli aipass init update", "Refresh managed scaffold files")
usage_table.add_row("drone @cli aipass init update /path", "Refresh scaffold in target directory")
console.print(usage_table)
console.print()
console.print("─" * 70)
console.print()
# What it creates
console.print("[bold cyan]WHAT IT CREATES:[/bold cyan]")
console.print()
files_text = """[bold]Project scaffold (12 items):[/bold]
[green]1.[/green] [yellow]{NAME}_REGISTRY.json[/yellow] Project registry with UUID
[green]2.[/green] [yellow].aipass/aipass_global_prompt.md[/yellow] Global prompt (injected every turn)
[green]3.[/green] [yellow]CLAUDE.md[/yellow] Project prompt (Claude Code)
[green]4.[/green] [yellow]AGENTS.md[/yellow] Codex instructions
[green]5.[/green] [yellow]GEMINI.md[/yellow] Gemini instructions
[green]6.[/green] [yellow]README.md[/yellow] Getting started guide
[green]7.[/green] [yellow]STATUS.local.md[/yellow] Project status
[green]8.[/green] [yellow].gitignore[/yellow] Standard AIPass ignores
[green]9.[/green] [yellow].claude/settings.json[/yellow] Claude Code hooks + AIPASS_HOME
[green]10.[/green] [yellow]src/[/yellow] Agent directories live here
[green]11.[/green] [yellow].ai_mail.local/inbox.json[/yellow] Empty project mailbox"""
console.print(Panel(files_text, border_style="green", padding=(1, 2), box=box.ROUNDED))
console.print()
console.print("─" * 70)
console.print()
# Arguments
console.print("[bold cyan]ARGUMENTS:[/bold cyan]")
console.print()
args_table = Table(show_header=True, header_style="bold cyan", border_style="dim")
args_table.add_column("Argument", style="green")
args_table.add_column("Required", style="yellow")
args_table.add_column("Default", style="dim")
args_table.add_column("Description", style="white")
args_table.add_row("target_dir", "No", "Current directory", "Directory to initialize")
args_table.add_row("project_name", "No", "Directory name", "Name for registry (auto-uppercased)")
console.print(args_table)
console.print()
console.print("─" * 70)
console.print()
console.print("[dim]Commands: init, init --help[/dim]")
console.print()
# =============================================================================
# MODULE EXPORTS
# =============================================================================
__all__ = [
"handle_command",
]
# =============================================================================
# ENTRY POINT (SEEDGO pattern)
# =============================================================================
if __name__ == "__main__":
try:
if len(sys.argv) == 1:
print_introspection()
sys.exit(0)
if sys.argv[1] in ("--help", "-h", "help"):
print_help()
sys.exit(0)
command = sys.argv[1]
cmd_args = sys.argv[2:] if len(sys.argv) > 2 else []
if handle_command(command, cmd_args):
sys.exit(0)
else:
console.print(f"[red]Unknown command: {command}[/red]")
console.print("[dim]Run 'drone @cli aipass --help' for usage[/dim]")
sys.exit(1)
except Exception as e:
logger.error("CLI init_project error: %s", e)
console.print(f"[red]Error: {e}[/red]")
sys.exit(1)
-608
View File
@@ -1,608 +0,0 @@
"""Tests for the CLI init_project module — aipass command routing and init orchestration."""
from io import StringIO
from unittest.mock import patch, MagicMock
import pytest
from rich.console import Console
from aipass.cli.apps.modules import init_project
from aipass.cli.apps.modules.init_project import (
handle_command,
_handle_init,
_handle_init_agent,
_handle_init_update,
print_introspection,
print_help,
_print_init_help,
)
# =============================================================================
# Helpers
# =============================================================================
def _make_capture_console():
"""Return (console, get_output) for capturing Rich output."""
buf = StringIO()
cons = Console(file=buf, no_color=True, width=120, highlight=False)
return cons, lambda: buf.getvalue()
# =============================================================================
# handle_command routing tests
# =============================================================================
class TestHandleCommandRouting:
"""Verify handle_command dispatches correctly and returns the right boolean."""
def test_non_aipass_command_returns_false(self):
"""Commands other than 'aipass' should return False."""
assert handle_command("other", []) is False
assert handle_command("display", []) is False
assert handle_command("", []) is False
def test_aipass_no_args_calls_introspection(self):
"""'aipass' with no args shows introspection."""
with patch.object(init_project, "print_introspection") as mock:
result = handle_command("aipass", [])
assert result is True
mock.assert_called_once()
def test_aipass_help_flag(self):
"""'aipass --help' shows full help."""
with patch.object(init_project, "print_help") as mock:
result = handle_command("aipass", ["--help"])
assert result is True
mock.assert_called_once()
def test_aipass_dash_h_flag(self):
"""'aipass -h' shows full help."""
with patch.object(init_project, "print_help") as mock:
result = handle_command("aipass", ["-h"])
assert result is True
mock.assert_called_once()
def test_aipass_help_word(self):
"""'aipass help' shows full help."""
with patch.object(init_project, "print_help") as mock:
result = handle_command("aipass", ["help"])
assert result is True
mock.assert_called_once()
def test_aipass_init_routes_to_handle_init(self):
"""'aipass init' routes to _handle_init."""
with patch.object(init_project, "_handle_init", return_value=True) as mock:
result = handle_command("aipass", ["init"])
assert result is True
mock.assert_called_once_with([])
def test_aipass_init_with_args_passes_through(self):
"""'aipass init /path Name' passes args to _handle_init."""
with patch.object(init_project, "_handle_init", return_value=True) as mock:
result = handle_command("aipass", ["init", "/path", "MyProj"])
assert result is True
mock.assert_called_once_with(["/path", "MyProj"])
def test_unknown_subcommand_shows_error(self):
"""Unknown aipass subcommand shows error and returns True."""
cons, get_output = _make_capture_console()
err_cons, get_err = _make_capture_console()
with patch.object(init_project, "console", cons), patch.object(init_project, "error") as mock_error:
result = handle_command("aipass", ["bogus"])
assert result is True
mock_error.assert_called_once()
args = mock_error.call_args
assert "bogus" in args[0][0]
# =============================================================================
# _handle_init tests
# =============================================================================
class TestHandleInit:
"""Tests for the init subcommand orchestration."""
def test_init_help_flag(self):
"""'init --help' shows init help and returns True."""
with patch.object(init_project, "_print_init_help") as mock:
result = _handle_init(["--help"])
assert result is True
mock.assert_called_once()
def test_init_success_displays_output(self, tmp_path):
"""Successful init displays results and logs the operation."""
target = tmp_path / "my_project"
cons, get_output = _make_capture_console()
err_cons, get_err = _make_capture_console()
from aipass.cli.apps.modules import display
with (
patch.object(init_project, "console", cons),
patch.object(display, "CONSOLE", cons),
patch.object(display, "err_console", err_cons),
patch.object(display, "_TRIGGER", None),
patch.object(display, "_TRIGGER_LOADED", True),
patch.object(init_project, "json_handler") as mock_json,
patch.object(init_project, "logger"),
):
result = _handle_init([str(target)])
assert result is True
output = get_output()
assert "Project Initialized" in output
mock_json.log_operation.assert_called_once()
call_args = mock_json.log_operation.call_args
assert call_args[0][0] == "aipass_init"
def test_init_displays_aipass_home(self, tmp_path):
"""When aipass_home is in result, displays AIPASS_HOME info."""
target = tmp_path / "home_proj"
cons, get_output = _make_capture_console()
err_cons, _ = _make_capture_console()
from aipass.cli.apps.modules import display
mock_result = {
"project_name": "HOME_PROJ",
"registry_file": "HOME_PROJ_REGISTRY.json",
"registry_id": "12345678-abcd-1234-5678-abcdef123456",
"target": str(target),
"created_files": ["file1", "file2"],
"aipass_home": "/fake/aipass",
}
with (
patch.object(init_project, "init_project", return_value=mock_result),
patch.object(init_project, "console", cons),
patch.object(display, "CONSOLE", cons),
patch.object(display, "err_console", err_cons),
patch.object(display, "_TRIGGER", None),
patch.object(display, "_TRIGGER_LOADED", True),
patch.object(init_project, "json_handler"),
patch.object(init_project, "logger"),
):
_handle_init([str(target)])
output = get_output()
assert "AIPASS_HOME" in output
assert "/fake/aipass" in output
def test_init_next_steps_shown(self, tmp_path):
"""After init, next steps guidance is shown."""
target = tmp_path / "steps_proj"
cons, get_output = _make_capture_console()
err_cons, _ = _make_capture_console()
from aipass.cli.apps.modules import display
with (
patch.object(init_project, "console", cons),
patch.object(display, "CONSOLE", cons),
patch.object(display, "err_console", err_cons),
patch.object(display, "_TRIGGER", None),
patch.object(display, "_TRIGGER_LOADED", True),
patch.object(init_project, "json_handler"),
patch.object(init_project, "logger"),
):
_handle_init([str(target)])
output = get_output()
assert "Next steps" in output
def test_init_value_error_exits(self, tmp_path):
"""ValueError from init_project causes error display and sys.exit(1)."""
with (
patch.object(init_project, "init_project", side_effect=ValueError("bad name")),
patch.object(init_project, "error") as mock_error,
patch.object(init_project, "logger"),
pytest.raises(SystemExit) as exc_info,
):
_handle_init([str(tmp_path)])
assert exc_info.value.code == 1
mock_error.assert_called_once()
assert "bad name" in mock_error.call_args[0][0]
def test_init_file_exists_error_exits(self, tmp_path):
"""FileExistsError from init_project causes error display and sys.exit(1)."""
with (
patch.object(init_project, "init_project", side_effect=FileExistsError("already exists")),
patch.object(init_project, "error") as mock_error,
patch.object(init_project, "logger"),
pytest.raises(SystemExit) as exc_info,
):
_handle_init([str(tmp_path)])
assert exc_info.value.code == 1
mock_error.assert_called_once()
def test_init_os_error_exits(self, tmp_path):
"""OSError from init_project causes error display and sys.exit(1)."""
with (
patch.object(init_project, "init_project", side_effect=OSError("disk full")),
patch.object(init_project, "error") as mock_error,
patch.object(init_project, "logger"),
pytest.raises(SystemExit) as exc_info,
):
_handle_init([str(tmp_path)])
assert exc_info.value.code == 1
mock_error.assert_called_once()
def test_init_uses_caller_cwd_env(self, tmp_path):
"""When no target arg, uses AIPASS_CALLER_CWD env var."""
import os
target = tmp_path / "env_project"
target.mkdir()
cons, get_output = _make_capture_console()
err_cons, get_err = _make_capture_console()
from aipass.cli.apps.modules import display
with (
patch.dict(os.environ, {"AIPASS_CALLER_CWD": str(target)}),
patch.object(init_project, "console", cons),
patch.object(display, "CONSOLE", cons),
patch.object(display, "err_console", err_cons),
patch.object(display, "_TRIGGER", None),
patch.object(display, "_TRIGGER_LOADED", True),
patch.object(init_project, "json_handler"),
patch.object(init_project, "logger"),
):
result = _handle_init([])
assert result is True
output = get_output()
assert "Project Initialized" in output
# =============================================================================
# Direct routing tests (command="init", command="update")
# =============================================================================
class TestDirectRouting:
"""Verify direct command routing for PATH-based invocation."""
def test_command_init_routes_directly(self):
"""command='init' routes directly to _handle_init."""
with patch.object(init_project, "_handle_init", return_value=True) as mock:
result = handle_command("init", ["--help"])
assert result is True
mock.assert_called_once_with(["--help"])
def test_command_update_routes_to_init_update(self):
"""command='update' routes to _handle_init with 'update' prepended."""
with patch.object(init_project, "_handle_init", return_value=True) as mock:
result = handle_command("update", ["/path"])
assert result is True
mock.assert_called_once_with(["update", "/path"])
# =============================================================================
# print_introspection / print_help / _print_init_help body execution
# =============================================================================
class TestOutputFunctions:
"""Verify output functions produce expected content when actually executed."""
def test_print_introspection_outputs_module_info(self):
"""print_introspection() outputs command table and handler info."""
cons, get_output = _make_capture_console()
from aipass.cli.apps.modules import display
with (
patch.object(init_project, "console", cons),
patch.object(display, "CONSOLE", cons),
):
print_introspection()
output = get_output()
assert "aipass" in output
assert "Project Commands" in output
assert "bootstrap.py" in output
def test_print_help_outputs_full_help(self):
"""print_help() outputs commands and file list."""
cons, get_output = _make_capture_console()
err_cons, _ = _make_capture_console()
from aipass.cli.apps.modules import display
with (
patch.object(init_project, "console", cons),
patch.object(display, "CONSOLE", cons),
patch.object(display, "err_console", err_cons),
patch.object(display, "_TRIGGER", None),
patch.object(display, "_TRIGGER_LOADED", True),
):
print_help()
output = get_output()
assert "COMMANDS:" in output
assert "WHAT INIT CREATES:" in output
assert "ARGUMENTS" in output
def test_print_init_help_outputs_usage(self):
"""_print_init_help() outputs usage examples and arguments."""
cons, get_output = _make_capture_console()
err_cons, _ = _make_capture_console()
from aipass.cli.apps.modules import display
with (
patch.object(init_project, "console", cons),
patch.object(display, "CONSOLE", cons),
patch.object(display, "err_console", err_cons),
patch.object(display, "_TRIGGER", None),
patch.object(display, "_TRIGGER_LOADED", True),
):
_print_init_help()
output = get_output()
assert "USAGE:" in output
assert "WHAT IT CREATES:" in output
assert "ARGUMENTS:" in output
# =============================================================================
# _handle_init_agent tests
# =============================================================================
class TestHandleInitAgent:
"""Tests for the 'aipass init agent' subcommand."""
def test_agent_help_no_args(self):
"""'init agent' with no args shows help."""
cons, get_output = _make_capture_console()
err_cons, _ = _make_capture_console()
from aipass.cli.apps.modules import display
with (
patch.object(init_project, "console", cons),
patch.object(display, "CONSOLE", cons),
patch.object(display, "err_console", err_cons),
patch.object(display, "_TRIGGER", None),
patch.object(display, "_TRIGGER_LOADED", True),
):
result = _handle_init_agent([])
assert result is True
output = get_output()
assert "Create an Agent" in output
def test_agent_help_flag(self):
"""'init agent --help' shows help."""
cons, get_output = _make_capture_console()
err_cons, _ = _make_capture_console()
from aipass.cli.apps.modules import display
with (
patch.object(init_project, "console", cons),
patch.object(display, "CONSOLE", cons),
patch.object(display, "err_console", err_cons),
patch.object(display, "_TRIGGER", None),
patch.object(display, "_TRIGGER_LOADED", True),
):
result = _handle_init_agent(["--help"])
assert result is True
def test_agent_success_calls_subprocess(self):
"""'init agent mybot' calls drone @spawn create src/mybot."""
mock_result = MagicMock()
mock_result.returncode = 0
with (
patch.object(init_project, "subprocess") as mock_sub,
patch.object(init_project, "logger"),
):
mock_sub.run.return_value = mock_result
result = _handle_init_agent(["mybot"])
assert result is True
mock_sub.run.assert_called_once()
call_args = mock_sub.run.call_args[0][0]
assert "src/mybot" in call_args
def test_agent_subprocess_nonzero_exit(self):
"""Non-zero subprocess exit shows error."""
mock_result = MagicMock()
mock_result.returncode = 1
with (
patch.object(init_project, "subprocess") as mock_sub,
patch.object(init_project, "error") as mock_error,
patch.object(init_project, "logger"),
):
mock_sub.run.return_value = mock_result
result = _handle_init_agent(["badbot"])
assert result is True
mock_error.assert_called_once()
def test_agent_forwards_extra_flags(self):
"""Extra flags like --role are forwarded to spawn."""
mock_result = MagicMock()
mock_result.returncode = 0
with (
patch.object(init_project, "subprocess") as mock_sub,
patch.object(init_project, "logger"),
):
mock_sub.run.return_value = mock_result
_handle_init_agent(["mybot", "--role", "builder"])
call_args = mock_sub.run.call_args[0][0]
assert "--role" in call_args
assert "builder" in call_args
def test_agent_file_not_found(self):
"""FileNotFoundError when drone is missing shows error."""
with (
patch.object(init_project, "subprocess") as mock_sub,
patch.object(init_project, "error") as mock_error,
patch.object(init_project, "logger"),
):
mock_sub.run.side_effect = FileNotFoundError("drone not found")
result = _handle_init_agent(["mybot"])
assert result is True
mock_error.assert_called_once()
assert "not found" in mock_error.call_args[0][0]
# =============================================================================
# _handle_init_update tests
# =============================================================================
class TestHandleInitUpdate:
"""Tests for the 'aipass init update' subcommand."""
def test_update_help_flag(self):
"""'init update --help' shows update help."""
cons, get_output = _make_capture_console()
err_cons, _ = _make_capture_console()
from aipass.cli.apps.modules import display
with (
patch.object(init_project, "console", cons),
patch.object(display, "CONSOLE", cons),
patch.object(display, "err_console", err_cons),
patch.object(display, "_TRIGGER", None),
patch.object(display, "_TRIGGER_LOADED", True),
):
result = _handle_init_update(["--help"])
assert result is True
output = get_output()
assert "Refresh Scaffold Files" in output
def test_update_success_with_updates(self, tmp_path):
"""Successful update with changed files displays updated table."""
cons, get_output = _make_capture_console()
err_cons, _ = _make_capture_console()
from aipass.cli.apps.modules import display
mock_result = {
"project_name": "TESTPROJ",
"target": str(tmp_path),
"updated_files": ["/a/CLAUDE.md", "/a/AGENTS.md"],
"already_current": ["/a/settings.json"],
"skipped_files": ["/a/README.md", "/a/REGISTRY.json"],
}
with (
patch.object(init_project, "update_project", return_value=mock_result),
patch.object(init_project, "console", cons),
patch.object(display, "CONSOLE", cons),
patch.object(display, "err_console", err_cons),
patch.object(display, "_TRIGGER", None),
patch.object(display, "_TRIGGER_LOADED", True),
patch.object(init_project, "json_handler"),
patch.object(init_project, "logger"),
patch.dict("os.environ", {"AIPASS_CALLER_CWD": str(tmp_path)}),
):
result = _handle_init_update([])
assert result is True
output = get_output()
assert "Project Updated" in output
assert "Updated" in output
def test_update_all_current(self, tmp_path):
"""When nothing changed, displays 'All files already up to date'."""
cons, get_output = _make_capture_console()
err_cons, _ = _make_capture_console()
from aipass.cli.apps.modules import display
mock_result = {
"project_name": "TESTPROJ",
"target": str(tmp_path),
"updated_files": [],
"already_current": ["/a/CLAUDE.md", "/a/settings.json"],
"skipped_files": ["/a/README.md"],
}
with (
patch.object(init_project, "update_project", return_value=mock_result),
patch.object(init_project, "console", cons),
patch.object(display, "CONSOLE", cons),
patch.object(display, "err_console", err_cons),
patch.object(display, "_TRIGGER", None),
patch.object(display, "_TRIGGER_LOADED", True),
patch.object(init_project, "json_handler"),
patch.object(init_project, "logger"),
patch.dict("os.environ", {"AIPASS_CALLER_CWD": str(tmp_path)}),
):
result = _handle_init_update([])
assert result is True
output = get_output()
assert "up to date" in output
def test_update_value_error_exits(self, tmp_path):
"""ValueError from update_project causes sys.exit(1)."""
with (
patch.object(init_project, "update_project", side_effect=ValueError("no registry")),
patch.object(init_project, "error") as mock_error,
patch.object(init_project, "logger"),
patch.dict("os.environ", {"AIPASS_CALLER_CWD": str(tmp_path)}),
pytest.raises(SystemExit) as exc_info,
):
_handle_init_update([])
assert exc_info.value.code == 1
mock_error.assert_called_once()
def test_update_os_error_exits(self, tmp_path):
"""OSError from update_project causes sys.exit(1)."""
with (
patch.object(init_project, "update_project", side_effect=OSError("read-only")),
patch.object(init_project, "error") as mock_error,
patch.object(init_project, "logger"),
patch.dict("os.environ", {"AIPASS_CALLER_CWD": str(tmp_path)}),
pytest.raises(SystemExit) as exc_info,
):
_handle_init_update([])
assert exc_info.value.code == 1
mock_error.assert_called_once()
def test_update_with_target_arg(self, tmp_path):
"""Target arg is passed to update_project."""
target = tmp_path / "myproj"
mock_result = {
"project_name": "MYPROJ",
"target": str(target),
"updated_files": [],
"already_current": [],
"skipped_files": [],
}
cons, _ = _make_capture_console()
err_cons, _ = _make_capture_console()
from aipass.cli.apps.modules import display
with (
patch.object(init_project, "update_project", return_value=mock_result) as mock_up,
patch.object(init_project, "console", cons),
patch.object(display, "CONSOLE", cons),
patch.object(display, "err_console", err_cons),
patch.object(display, "_TRIGGER", None),
patch.object(display, "_TRIGGER_LOADED", True),
patch.object(init_project, "json_handler"),
patch.object(init_project, "logger"),
):
_handle_init_update([str(target)])
mock_up.assert_called_once()
def test_update_relative_path_resolves_against_caller_cwd(self, tmp_path):
"""Relative path resolves against AIPASS_CALLER_CWD."""
from pathlib import Path
mock_result = {
"project_name": "PROJ",
"target": str(tmp_path / "rel"),
"updated_files": [],
"already_current": [],
"skipped_files": [],
}
cons, _ = _make_capture_console()
err_cons, _ = _make_capture_console()
from aipass.cli.apps.modules import display
with (
patch.object(init_project, "update_project", return_value=mock_result) as mock_up,
patch.object(init_project, "console", cons),
patch.object(display, "CONSOLE", cons),
patch.object(display, "err_console", err_cons),
patch.object(display, "_TRIGGER", None),
patch.object(display, "_TRIGGER_LOADED", True),
patch.object(init_project, "json_handler"),
patch.object(init_project, "logger"),
patch.dict("os.environ", {"AIPASS_CALLER_CWD": str(tmp_path)}),
):
_handle_init_update(["rel"])
called_target = mock_up.call_args[0][0]
assert called_target == Path(tmp_path) / "rel"
-15
View File
@@ -104,21 +104,6 @@ class TestMainFlow:
err_output = get_err()
assert "Unknown command" in err_output
def test_main_aipass_init_help_returns_zero(self):
"""'aipass init --help' returns 0."""
cons, _get_output = _make_capture_console()
err_cons, _get_err = _make_capture_console()
with (
patch.object(cli_module, "CONSOLE", cons),
patch.object(display, "CONSOLE", cons),
patch.object(display, "err_console", err_cons),
patch.object(display, "_TRIGGER", None),
patch.object(display, "_TRIGGER_LOADED", True),
patch("sys.argv", ["cli", "aipass", "init", "--help"]),
):
result = main()
assert result == 0
def test_main_display_demo_returns_zero(self):
"""'display demo' returns 0."""
cons, _get_output = _make_capture_console()