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:
@@ -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()
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)*
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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*
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user