diff --git a/src/aipass/aipass/.aipass/aipass_global_prompt.md b/src/aipass/aipass/.aipass/aipass_global_prompt.md new file mode 100644 index 00000000..c9695508 --- /dev/null +++ b/src/aipass/aipass/.aipass/aipass_global_prompt.md @@ -0,0 +1,109 @@ + +# AIPASS — Project Context + + +## 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 # Create a new agent in src// +drone @spawn create # Create agent (alternative) +drone @spawn list # List registered agents +``` + +### Standards +``` +drone @seedgo audit # Run full standards audit +drone @seedgo checklist # Check a single file +``` + +### Dispatch — Send Task + Wake an Agent (DEFAULT) +``` +drone @ai_mail dispatch @ "Subject" "Body" # Send + wake (default) +drone @ai_mail dispatch @ "Subject" "Body" --fresh # Send + wake fresh session +drone @ai_mail dispatch wake @ # Wake without sending +drone @ai_mail dispatch wake --fresh @ # Wake fresh +drone @ai_mail email @ "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 # Read a message +drone @ai_mail close # 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 # Close a plan +drone @flow info # 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 # 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. diff --git a/src/aipass/aipass/.claude/commands/memo.md b/src/aipass/aipass/.claude/commands/memo.md new file mode 100644 index 00000000..948ac39b --- /dev/null +++ b/src/aipass/aipass/.claude/commands/memo.md @@ -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. diff --git a/src/aipass/aipass/.claude/commands/prep.md b/src/aipass/aipass/.claude/commands/prep.md new file mode 100644 index 00000000..4a56e0a3 --- /dev/null +++ b/src/aipass/aipass/.claude/commands/prep.md @@ -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. +``` diff --git a/src/aipass/aipass/.claude/hooks/auto_fix_diagnostics.py b/src/aipass/aipass/.claude/hooks/auto_fix_diagnostics.py new file mode 100644 index 00000000..1ed056be --- /dev/null +++ b/src/aipass/aipass/.claude/hooks/auto_fix_diagnostics.py @@ -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() diff --git a/src/aipass/aipass/.claude/hooks/branch_prompt_loader.py b/src/aipass/aipass/.claude/hooks/branch_prompt_loader.py new file mode 100644 index 00000000..07608bcf --- /dev/null +++ b/src/aipass/aipass/.claude/hooks/branch_prompt_loader.py @@ -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\n{content}") + + +if __name__ == "__main__": + main() diff --git a/src/aipass/aipass/.claude/hooks/email_notification.py b/src/aipass/aipass/.claude/hooks/email_notification.py new file mode 100644 index 00000000..08166950 --- /dev/null +++ b/src/aipass/aipass/.claude/hooks/email_notification.py @@ -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 | close with: drone @ai_mail close ") + + +if __name__ == "__main__": + main() diff --git a/src/aipass/aipass/.claude/hooks/identity_injector.py b/src/aipass/aipass/.claude/hooks/identity_injector.py new file mode 100644 index 00000000..10821aaf --- /dev/null +++ b/src/aipass/aipass/.claude/hooks/identity_injector.py @@ -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() diff --git a/src/aipass/aipass/.claude/hooks/pre_compact.py b/src/aipass/aipass/.claude/hooks/pre_compact.py new file mode 100644 index 00000000..8191e4b2 --- /dev/null +++ b/src/aipass/aipass/.claude/hooks/pre_compact.py @@ -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() diff --git a/src/aipass/aipass/.claude/hooks/pre_edit_gate.py b/src/aipass/aipass/.claude/hooks/pre_edit_gate.py new file mode 100644 index 00000000..93131beb --- /dev/null +++ b/src/aipass/aipass/.claude/hooks/pre_edit_gate.py @@ -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 @ "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() diff --git a/src/aipass/aipass/.claude/hooks/subagent_stop_gate.py b/src/aipass/aipass/.claude/hooks/subagent_stop_gate.py new file mode 100644 index 00000000..4140f551 --- /dev/null +++ b/src/aipass/aipass/.claude/hooks/subagent_stop_gate.py @@ -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() diff --git a/src/aipass/aipass/.claude/settings.json b/src/aipass/aipass/.claude/settings.json new file mode 100644 index 00000000..93277189 --- /dev/null +++ b/src/aipass/aipass/.claude/settings.json @@ -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" + } +} diff --git a/src/aipass/aipass/AGENTS.md b/src/aipass/aipass/AGENTS.md new file mode 100644 index 00000000..fe385d62 --- /dev/null +++ b/src/aipass/aipass/AGENTS.md @@ -0,0 +1,30 @@ + +# 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 +``` + +## Available Commands + +``` +aipass init agent # Create a new agent +drone @spawn create # Create agent (alternative) +drone @seedgo audit # Run standards audit +drone systems # List all infrastructure +``` + +## Startup + +On startup, read: `AIPASS_REGISTRY.json`, `README.md`, `STATUS.local.md` diff --git a/src/aipass/aipass/GEMINI.md b/src/aipass/aipass/GEMINI.md new file mode 100644 index 00000000..7a84f4fb --- /dev/null +++ b/src/aipass/aipass/GEMINI.md @@ -0,0 +1,27 @@ + +# 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 ` + +## Available Commands + +``` +aipass init agent # Create a new agent +drone @spawn create # Create agent (alternative) +drone @seedgo audit # Run standards audit +drone systems # List all infrastructure +``` + +## Startup + +On startup, read: `AIPASS_REGISTRY.json`, `README.md`, `STATUS.local.md` diff --git a/src/aipass/aipass/apps/handlers/init/__init__.py b/src/aipass/aipass/apps/handlers/init/__init__.py index e69de29b..4b878da4 100644 --- a/src/aipass/aipass/apps/handlers/init/__init__.py +++ b/src/aipass/aipass/apps/handlers/init/__init__.py @@ -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", +] diff --git a/src/aipass/aipass/status/.aipass/aipass_global_prompt.md b/src/aipass/aipass/status/.aipass/aipass_global_prompt.md new file mode 100644 index 00000000..37313a03 --- /dev/null +++ b/src/aipass/aipass/status/.aipass/aipass_global_prompt.md @@ -0,0 +1,109 @@ + +# STATUS — Project Context + + +## 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 # Create a new agent in src// +drone @spawn create # Create agent (alternative) +drone @spawn list # List registered agents +``` + +### Standards +``` +drone @seedgo audit # Run full standards audit +drone @seedgo checklist # Check a single file +``` + +### Dispatch — Send Task + Wake an Agent (DEFAULT) +``` +drone @ai_mail dispatch @ "Subject" "Body" # Send + wake (default) +drone @ai_mail dispatch @ "Subject" "Body" --fresh # Send + wake fresh session +drone @ai_mail dispatch wake @ # Wake without sending +drone @ai_mail dispatch wake --fresh @ # Wake fresh +drone @ai_mail email @ "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 # Read a message +drone @ai_mail close # 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 # Close a plan +drone @flow info # 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 # 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. diff --git a/src/aipass/aipass/status/.claude/commands/memo.md b/src/aipass/aipass/status/.claude/commands/memo.md new file mode 100644 index 00000000..948ac39b --- /dev/null +++ b/src/aipass/aipass/status/.claude/commands/memo.md @@ -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. diff --git a/src/aipass/aipass/status/.claude/commands/prep.md b/src/aipass/aipass/status/.claude/commands/prep.md new file mode 100644 index 00000000..4a56e0a3 --- /dev/null +++ b/src/aipass/aipass/status/.claude/commands/prep.md @@ -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. +``` diff --git a/src/aipass/aipass/status/.claude/hooks/auto_fix_diagnostics.py b/src/aipass/aipass/status/.claude/hooks/auto_fix_diagnostics.py new file mode 100644 index 00000000..1ed056be --- /dev/null +++ b/src/aipass/aipass/status/.claude/hooks/auto_fix_diagnostics.py @@ -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() diff --git a/src/aipass/aipass/status/.claude/hooks/branch_prompt_loader.py b/src/aipass/aipass/status/.claude/hooks/branch_prompt_loader.py new file mode 100644 index 00000000..07608bcf --- /dev/null +++ b/src/aipass/aipass/status/.claude/hooks/branch_prompt_loader.py @@ -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\n{content}") + + +if __name__ == "__main__": + main() diff --git a/src/aipass/aipass/status/.claude/hooks/email_notification.py b/src/aipass/aipass/status/.claude/hooks/email_notification.py new file mode 100644 index 00000000..08166950 --- /dev/null +++ b/src/aipass/aipass/status/.claude/hooks/email_notification.py @@ -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 | close with: drone @ai_mail close ") + + +if __name__ == "__main__": + main() diff --git a/src/aipass/aipass/status/.claude/hooks/identity_injector.py b/src/aipass/aipass/status/.claude/hooks/identity_injector.py new file mode 100644 index 00000000..10821aaf --- /dev/null +++ b/src/aipass/aipass/status/.claude/hooks/identity_injector.py @@ -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() diff --git a/src/aipass/aipass/status/.claude/hooks/pre_compact.py b/src/aipass/aipass/status/.claude/hooks/pre_compact.py new file mode 100644 index 00000000..8191e4b2 --- /dev/null +++ b/src/aipass/aipass/status/.claude/hooks/pre_compact.py @@ -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() diff --git a/src/aipass/aipass/status/.claude/hooks/pre_edit_gate.py b/src/aipass/aipass/status/.claude/hooks/pre_edit_gate.py new file mode 100644 index 00000000..93131beb --- /dev/null +++ b/src/aipass/aipass/status/.claude/hooks/pre_edit_gate.py @@ -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 @ "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() diff --git a/src/aipass/aipass/status/.claude/hooks/subagent_stop_gate.py b/src/aipass/aipass/status/.claude/hooks/subagent_stop_gate.py new file mode 100644 index 00000000..4140f551 --- /dev/null +++ b/src/aipass/aipass/status/.claude/hooks/subagent_stop_gate.py @@ -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() diff --git a/src/aipass/aipass/status/.claude/settings.json b/src/aipass/aipass/status/.claude/settings.json new file mode 100644 index 00000000..93277189 --- /dev/null +++ b/src/aipass/aipass/status/.claude/settings.json @@ -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" + } +} diff --git a/src/aipass/aipass/status/.gitignore b/src/aipass/aipass/status/.gitignore new file mode 100644 index 00000000..969f6900 --- /dev/null +++ b/src/aipass/aipass/status/.gitignore @@ -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)* diff --git a/src/aipass/aipass/status/AGENTS.md b/src/aipass/aipass/status/AGENTS.md new file mode 100644 index 00000000..60754d4b --- /dev/null +++ b/src/aipass/aipass/status/AGENTS.md @@ -0,0 +1,30 @@ + +# 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 +``` + +## Available Commands + +``` +aipass init agent # Create a new agent +drone @spawn create # Create agent (alternative) +drone @seedgo audit # Run standards audit +drone systems # List all infrastructure +``` + +## Startup + +On startup, read: `STATUS_REGISTRY.json`, `README.md`, `STATUS.local.md` diff --git a/src/aipass/aipass/status/CLAUDE.md b/src/aipass/aipass/status/CLAUDE.md new file mode 100644 index 00000000..528f9445 --- /dev/null +++ b/src/aipass/aipass/status/CLAUDE.md @@ -0,0 +1,41 @@ + +# 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 +``` + +This creates a full agent scaffold inside `src//` (`apps/`, `.trinity/`, `.ai_mail.local/`) and registers it in your project registry. + +## Available Commands + +``` +aipass init agent # Create a new agent +drone @spawn create # Create agent (alternative) +drone @seedgo audit # 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. diff --git a/src/aipass/aipass/status/GEMINI.md b/src/aipass/aipass/status/GEMINI.md new file mode 100644 index 00000000..fee1aac0 --- /dev/null +++ b/src/aipass/aipass/status/GEMINI.md @@ -0,0 +1,27 @@ + +# 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 ` + +## Available Commands + +``` +aipass init agent # Create a new agent +drone @spawn create # Create agent (alternative) +drone @seedgo audit # Run standards audit +drone systems # List all infrastructure +``` + +## Startup + +On startup, read: `STATUS_REGISTRY.json`, `README.md`, `STATUS.local.md` diff --git a/src/aipass/aipass/status/README.md b/src/aipass/aipass/status/README.md new file mode 100644 index 00000000..2a2480fd --- /dev/null +++ b/src/aipass/aipass/status/README.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 + / # 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 ` | Create a new agent | +| `drone @spawn create ` | Create agent (alternative) | +| `drone @seedgo audit ` | 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* diff --git a/src/aipass/aipass/status/STATUS.local.md b/src/aipass/aipass/status/STATUS.local.md new file mode 100644 index 00000000..86c0c6b9 --- /dev/null +++ b/src/aipass/aipass/status/STATUS.local.md @@ -0,0 +1,9 @@ +# STATUS + +**State:** New +**Last update:** 2026-05-04 + +## Current Work + +## Known Issues +- None diff --git a/src/aipass/aipass/status/STATUS_REGISTRY.json b/src/aipass/aipass/status/STATUS_REGISTRY.json new file mode 100644 index 00000000..d9c9bdf9 --- /dev/null +++ b/src/aipass/aipass/status/STATUS_REGISTRY.json @@ -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": [] +} diff --git a/src/aipass/cli/tests/test_bootstrap.py b/src/aipass/aipass/tests/test_bootstrap.py similarity index 98% rename from src/aipass/cli/tests/test_bootstrap.py rename to src/aipass/aipass/tests/test_bootstrap.py index 817dae85..35352c87 100644 --- a/src/aipass/cli/tests/test_bootstrap.py +++ b/src/aipass/aipass/tests/test_bootstrap.py @@ -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, ) diff --git a/src/aipass/aipass/tests/test_init_flow.py b/src/aipass/aipass/tests/test_init_flow.py index 7b8a6cac..5d8d6ead 100644 --- a/src/aipass/aipass/tests/test_init_flow.py +++ b/src/aipass/aipass/tests/test_init_flow.py @@ -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.""" diff --git a/src/aipass/cli/.seedgo/bypass.json b/src/aipass/cli/.seedgo/bypass.json index 6d352919..d7943bcd 100644 --- a/src/aipass/cli/.seedgo/bypass.json +++ b/src/aipass/cli/.seedgo/bypass.json @@ -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": { diff --git a/src/aipass/cli/README.md b/src/aipass/cli/README.md index e2c9a147..d4909b60 100644 --- a/src/aipass/cli/README.md +++ b/src/aipass/cli/README.md @@ -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 # 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) diff --git a/src/aipass/cli/apps/cli.py b/src/aipass/cli/apps/cli.py index 0135ef0a..c1d1db7c 100755 --- a/src/aipass/cli/apps/cli.py +++ b/src/aipass/cli/apps/cli.py @@ -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() diff --git a/src/aipass/cli/apps/handlers/init/__init__.py b/src/aipass/cli/apps/handlers/init/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/aipass/cli/apps/handlers/init/bootstrap.py b/src/aipass/cli/apps/handlers/init/bootstrap.py deleted file mode 100644 index 67ea0e3e..00000000 --- a/src/aipass/cli/apps/handlers/init/bootstrap.py +++ /dev/null @@ -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, - } diff --git a/src/aipass/cli/apps/handlers/init/scaffold_content.py b/src/aipass/cli/apps/handlers/init/scaffold_content.py deleted file mode 100644 index d1ce34a1..00000000 --- a/src/aipass/cli/apps/handlers/init/scaffold_content.py +++ /dev/null @@ -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 \n" - "```\n" - "\n" - "This creates a full agent scaffold inside `src//` " - "(`apps/`, `.trinity/`, `.ai_mail.local/`) " - "and registers it in your project registry.\n" - "\n" - "## Available Commands\n" - "\n" - "```\n" - "aipass init agent # Create a new agent\n" - "drone @spawn create # Create agent (alternative)\n" - "drone @seedgo audit # 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 \n" - "```\n" - "\n" - "## Available Commands\n" - "\n" - "```\n" - "aipass init agent # Create a new agent\n" - "drone @spawn create # Create agent (alternative)\n" - "drone @seedgo audit # 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 `\n" - "\n" - "## Available Commands\n" - "\n" - "```\n" - "aipass init agent # Create a new agent\n" - "drone @spawn create # Create agent (alternative)\n" - "drone @seedgo audit # 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" - " / # 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 ` | Create a new agent |\n" - "| `drone @spawn create ` | Create agent (alternative) |\n" - "| `drone @seedgo audit ` | 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" - "\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 # Create a new agent in src//\n" - "drone @spawn create # Create agent (alternative)\n" - "drone @spawn list # List registered agents\n" - "```\n" - "\n" - "### Standards\n" - "```\n" - "drone @seedgo audit # Run full standards audit\n" - "drone @seedgo checklist # Check a single file\n" - "```\n" - "\n" - "### Dispatch — Send Task + Wake an Agent (DEFAULT)\n" - "```\n" - 'drone @ai_mail dispatch @ "Subject" "Body" # Send + wake (default)\n' - 'drone @ai_mail dispatch @ "Subject" "Body" --fresh # Send + wake fresh session\n' - "drone @ai_mail dispatch wake @ # Wake without sending\n" - "drone @ai_mail dispatch wake --fresh @ # Wake fresh\n" - 'drone @ai_mail email @ "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 # Read a message\n" - "drone @ai_mail close # 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 # Close a plan\n" - "drone @flow info # 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 # 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"\n{content}" diff --git a/src/aipass/cli/apps/modules/init_project.py b/src/aipass/cli/apps/modules/init_project.py deleted file mode 100644 index 119b6992..00000000 --- a/src/aipass/cli/apps/modules/init_project.py +++ /dev/null @@ -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 [/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 ' 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 [/yellow]") - console.print(" [green]2.[/green] Start a session: [dim]cd src// && 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 ' — 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 [/green]") - console.print(" [green]drone @cli aipass init agent [/green]") - console.print() - console.print("[bold cyan]What it does:[/bold cyan]") - console.print(" Routes to [yellow]drone @spawn create src/[/yellow]") - console.print(" Creates full agent scaffold in src// (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) diff --git a/src/aipass/cli/tests/test_init_project.py b/src/aipass/cli/tests/test_init_project.py deleted file mode 100644 index 89d61c6f..00000000 --- a/src/aipass/cli/tests/test_init_project.py +++ /dev/null @@ -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" diff --git a/src/aipass/cli/tests/test_integration.py b/src/aipass/cli/tests/test_integration.py index 9968d906..c34f99e3 100644 --- a/src/aipass/cli/tests/test_integration.py +++ b/src/aipass/cli/tests/test_integration.py @@ -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()