diff --git a/.claude/settings.json b/.claude/settings.json index 1d8ef467..971803f1 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -7,7 +7,13 @@ "deny": [ "EnterPlanMode", "Bash(git add -f*)", - "Bash(git add --force*)" + "Bash(git add --force*)", + "Read(/home/patrick/Patrick-Personal/**)", + "Edit(/home/patrick/Patrick-Personal/**)", + "Write(/home/patrick/Patrick-Personal/**)", + "Glob(/home/patrick/Patrick-Personal/**)", + "Grep(/home/patrick/Patrick-Personal/**)", + "Bash(*Patrick-Personal*)" ], "defaultMode": "acceptEdits" }, diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/wake.py b/src/aipass/ai_mail/apps/handlers/dispatch/wake.py index 64d29516..6e4aaf80 100644 --- a/src/aipass/ai_mail/apps/handlers/dispatch/wake.py +++ b/src/aipass/ai_mail/apps/handlers/dispatch/wake.py @@ -40,6 +40,14 @@ MONITOR_SCRIPT = Path(__file__).parent / "dispatch_monitor.py" # Default prompt when no custom message provided DEFAULT_PROMPT = "Hi. Check inbox, process new emails, update memories when done." +# Model shorthand mapping +MODEL_MAP = { + "sonnet": "claude-sonnet-4-6", + "opus": "claude-opus-4-6", + "haiku": "claude-haiku-4-5-20251001", +} +DEFAULT_MODEL = "sonnet" + # ─── Status Step Tracking ─────────────────────────────── @@ -309,14 +317,24 @@ def resolve_branch(branch_email: str) -> Optional[Tuple[Path, str]]: def wake_branch(branch_email: str, custom_message: Optional[str] = None, fresh: bool = False, auto: bool = False, - sender: str = "@devpulse") -> Tuple[DispatchStatus, bool]: + sender: str = "@devpulse", + model: Optional[str] = None) -> Tuple[DispatchStatus, bool]: """ Spawn a Claude agent at the target branch with step-by-step status. + Args: + branch_email: Target branch email (e.g. "@flow") + custom_message: Optional custom prompt (replaces default inbox check) + fresh: If True, start fresh session instead of resuming + auto: If True, respect autonomous_pause (used by daemon) + sender: Return-to-sender for bounce emails + model: Model shorthand ("sonnet", "opus", "haiku") or full model ID. + Defaults to sonnet (claude-sonnet-4-6). + Returns: Tuple of (DispatchStatus with all steps, overall success bool) """ - json_handler.log_operation("wake_branch", {"branch": branch_email, "fresh": fresh, "auto": auto}) + json_handler.log_operation("wake_branch", {"branch": branch_email, "fresh": fresh, "auto": auto, "model": model or DEFAULT_MODEL}) status = DispatchStatus() @@ -371,6 +389,9 @@ def wake_branch(branch_email: str, custom_message: Optional[str] = None, config = _load_config() max_turns = config.get("max_turns_per_wake", 100) + # Resolve model: shorthand -> full ID, or pass through if already a full ID + resolved_model = MODEL_MAP.get(model or DEFAULT_MODEL, model or MODEL_MAP[DEFAULT_MODEL]) + lock_file_path = str(branch_path / ".ai_mail.local" / ".dispatch.lock") if custom_message: prompt = f"Hi. {custom_message} " @@ -382,6 +403,7 @@ def wake_branch(branch_email: str, custom_message: Optional[str] = None, if fresh: claude_cmd = [ "claude", "-p", prompt, + "--model", resolved_model, "--max-turns", str(max_turns), "--permission-mode", "bypassPermissions", "--output-format", "json" @@ -389,6 +411,7 @@ def wake_branch(branch_email: str, custom_message: Optional[str] = None, else: claude_cmd = [ "claude", "-c", "-p", prompt, + "--model", resolved_model, "--max-turns", str(max_turns), "--permission-mode", "bypassPermissions", "--output-format", "json" @@ -481,13 +504,14 @@ if __name__ == "__main__": args = sys.argv[1:] if not args or args[0] in ("--help", "-h"): - print("Usage: wake.py [--fresh] [--auto] [--sender @branch] @branch [\"optional message\"]") + print("Usage: wake.py [--fresh] [--auto] [--sender @branch] [--model sonnet|opus] @branch [\"optional message\"]") print(" Manually spawn a Claude agent at a branch (daemon not required)") print() print("Flags:") print(" --fresh Start fresh session (claude -p) instead of resuming (claude -c -p)") print(" --auto Respect autonomous_pause (used by daemon). Manual wake ignores it.") print(" --sender @branch Set return-to-sender for bounce emails (default: @devpulse)") + print(" --model NAME Model to use: sonnet (default), opus, haiku, or full model ID") print() print("Output: Step-by-step status of the dispatch pipeline:") print(" ✅ resolve → @branch found at /path/to/branch") @@ -508,6 +532,7 @@ if __name__ == "__main__": use_fresh = "--fresh" in args use_auto = "--auto" in args use_sender = "@devpulse" + use_model = None if "--sender" in args: idx = args.index("--sender") @@ -515,6 +540,12 @@ if __name__ == "__main__": use_sender = args[idx + 1] args = args[:idx] + args[idx + 2:] + if "--model" in args: + idx = args.index("--model") + if idx + 1 < len(args): + use_model = args[idx + 1] + args = args[:idx] + args[idx + 2:] + args = [a for a in args if a not in ("--fresh", "--auto")] if not args: @@ -525,7 +556,8 @@ if __name__ == "__main__": message = args[1] if len(args) > 1 else None dispatch_status, success = wake_branch( - branch, message, fresh=use_fresh, auto=use_auto, sender=use_sender + branch, message, fresh=use_fresh, auto=use_auto, sender=use_sender, + model=use_model ) print(dispatch_status.format()) sys.exit(0 if success else 1) diff --git a/src/aipass/ai_mail/apps/modules/dispatch.py b/src/aipass/ai_mail/apps/modules/dispatch.py index 958c645d..ec6af82c 100644 --- a/src/aipass/ai_mail/apps/modules/dispatch.py +++ b/src/aipass/ai_mail/apps/modules/dispatch.py @@ -39,14 +39,21 @@ COMMANDS: dispatch wake @branch - Wake only (no email sent) DISPATCH (send + wake): - drone @ai_mail dispatch @branch "Subject" "Body" # Send + continue wake - drone @ai_mail dispatch @branch "Subject" "Body" --fresh # Send + fresh wake + drone @ai_mail dispatch @branch "Subject" "Body" # Send + continue wake + drone @ai_mail dispatch @branch "Subject" "Body" --fresh # Send + fresh wake + drone @ai_mail dispatch @branch "Subject" "Body" --model opus # Send + wake with Opus drone @ai_mail dispatch @branch "Subject" "Body" --no-memory-save WAKE ONLY: - drone @ai_mail dispatch wake @branch # Wake with default inbox check - drone @ai_mail dispatch wake @branch "custom" # Wake with custom prompt - drone wake @branch # Shortcut via drone + drone @ai_mail dispatch wake @branch # Wake with default inbox check + drone @ai_mail dispatch wake @branch "custom" # Wake with custom prompt + drone @ai_mail dispatch wake @branch --model opus # Wake with Opus model + drone wake @branch # Shortcut via drone + +MODEL OPTIONS: + --model sonnet Claude Sonnet 4.6 (default — cost-effective for most tasks) + --model opus Claude Opus 4.6 (complex tasks needing deeper reasoning) + --model haiku Claude Haiku 4.5 (fastest, simplest tasks) DAEMON: The daemon polls branch inboxes for --dispatch emails and spawns agents. @@ -156,9 +163,10 @@ def _orchestrate_wake(args: List[str]) -> bool: console.print(" Or: drone wake @branch [\"custom message\"]\n") return True - # Parse --fresh and --sender flags + # Parse --fresh, --sender, --model flags use_fresh = "--fresh" in args use_sender = "@devpulse" + use_model = None filtered = [] i = 0 while i < len(args): @@ -169,6 +177,10 @@ def _orchestrate_wake(args: List[str]) -> bool: use_sender = args[i + 1] i += 2 continue + if args[i] == "--model" and i + 1 < len(args): + use_model = args[i + 1] + i += 2 + continue filtered.append(args[i]) i += 1 @@ -184,7 +196,8 @@ def _orchestrate_wake(args: List[str]) -> bool: from aipass.ai_mail.apps.handlers.dispatch.wake import wake_branch dispatch_status, success = wake_branch( - branch_email, custom_message, fresh=use_fresh, sender=use_sender + branch_email, custom_message, fresh=use_fresh, sender=use_sender, + model=use_model ) # Print step-by-step status @@ -199,6 +212,7 @@ def _orchestrate_dispatch_send(args: List[str]) -> bool: use_fresh = False no_memory_save = False from_branch = None + use_model = None filtered = [] i = 0 while i < len(args): @@ -214,6 +228,10 @@ def _orchestrate_dispatch_send(args: List[str]) -> bool: from_branch = args[i + 1] i += 2 continue + if args[i] == "--model" and i + 1 < len(args): + use_model = args[i + 1] + i += 2 + continue filtered.append(args[i]) i += 1 @@ -293,7 +311,8 @@ def _orchestrate_dispatch_send(args: List[str]) -> bool: from aipass.ai_mail.apps.handlers.dispatch.wake import wake_branch dispatch_status, wake_ok = wake_branch( target, fresh=use_fresh, - sender=user_info.get("email_address", "@ai_mail") + sender=user_info.get("email_address", "@ai_mail"), + model=use_model ) console.print(dispatch_status.format()) diff --git a/src/aipass/ai_mail/tests/test_wake.py b/src/aipass/ai_mail/tests/test_wake.py index 7cebc7b4..e5d0e214 100644 --- a/src/aipass/ai_mail/tests/test_wake.py +++ b/src/aipass/ai_mail/tests/test_wake.py @@ -434,3 +434,28 @@ def _fake_open_factory(real_status_path, mapping): return _real_open(mapping[path_str], *args, **kwargs) return _real_open(path, *args, **kwargs) return _fake_open + + +# --- Model flag tests --------------------------------------------------- + +from aipass.ai_mail.apps.handlers.dispatch.wake import MODEL_MAP, DEFAULT_MODEL + + +def test_model_map_has_expected_entries(): + """MODEL_MAP should contain sonnet, opus, haiku shorthand mappings.""" + assert "sonnet" in MODEL_MAP + assert "opus" in MODEL_MAP + assert "haiku" in MODEL_MAP + assert "claude-sonnet-4-6" in MODEL_MAP["sonnet"] + assert "claude-opus-4-6" in MODEL_MAP["opus"] + + +def test_default_model_is_sonnet(): + """Default model should be sonnet.""" + assert DEFAULT_MODEL == "sonnet" + + +def test_model_map_values_are_full_ids(): + """All MODEL_MAP values should be full claude model IDs.""" + for key, value in MODEL_MAP.items(): + assert value.startswith("claude-"), f"{key} -> {value} doesn't start with 'claude-'" diff --git a/src/aipass/cli/apps/handlers/init/bootstrap.py b/src/aipass/cli/apps/handlers/init/bootstrap.py index b130813e..6ac3f20b 100644 --- a/src/aipass/cli/apps/handlers/init/bootstrap.py +++ b/src/aipass/cli/apps/handlers/init/bootstrap.py @@ -331,6 +331,19 @@ def _claude_settings() -> str: ) + "\n" +def _with_source(content: str, file_path: Path) -> str: + """Prepend a source header to AI prompt file content. + + Args: + content: The file content to annotate. + file_path: The absolute path where the file will be written. + + Returns: + Content with ```` as the first line. + """ + return f"\n{content}" + + def init_project(target: Path, project_name: str | None = None) -> dict: """Initialize an AIPass project in the target directory. @@ -391,25 +404,37 @@ def init_project(target: Path, project_name: str | None = None) -> dict: global_prompt_path = aipass_dir / "aipass_global_prompt.md" if not global_prompt_path.exists(): - global_prompt_path.write_text(_global_prompt_md(name), encoding="utf-8") + global_prompt_path.write_text( + _with_source(_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(_claude_md(name), encoding="utf-8") + claude_md_path.write_text( + _with_source(_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(_agents_md(name), encoding="utf-8") + agents_md_path.write_text( + _with_source(_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(_gemini_md(name), encoding="utf-8") + gemini_md_path.write_text( + _with_source(_gemini_md(name), gemini_md_path), + encoding="utf-8", + ) created.append(str(gemini_md_path)) # 6. README.md @@ -467,3 +492,93 @@ def init_project(target: Path, project_name: str | None = None) -> dict: "target": str(target), "created_files": created, } + + +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, + hooks/, 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() + + # 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] = [] + skipped: list[str] = [] + + # 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: always overwrite with latest templates --- + + global_prompt_path = aipass_dir / "aipass_global_prompt.md" + global_prompt_path.write_text( + _with_source(_global_prompt_md(name), global_prompt_path), + encoding="utf-8", + ) + updated.append(str(global_prompt_path)) + + settings_path = claude_dir / "settings.json" + settings_path.write_text(_claude_settings(), encoding="utf-8") + updated.append(str(settings_path)) + + claude_md_path = target / "CLAUDE.md" + claude_md_path.write_text( + _with_source(_claude_md(name), claude_md_path), + encoding="utf-8", + ) + updated.append(str(claude_md_path)) + + agents_md_path = target / "AGENTS.md" + agents_md_path.write_text( + _with_source(_agents_md(name), agents_md_path), + encoding="utf-8", + ) + updated.append(str(agents_md_path)) + + gemini_md_path = target / "GEMINI.md" + gemini_md_path.write_text( + _with_source(_gemini_md(name), gemini_md_path), + encoding="utf-8", + ) + updated.append(str(gemini_md_path)) + + # --- 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) + + return { + "project_name": name, + "target": str(target), + "updated_files": updated, + "skipped_files": skipped, + } diff --git a/src/aipass/cli/apps/modules/init_project.py b/src/aipass/cli/apps/modules/init_project.py index f8c50135..d6b45239 100644 --- a/src/aipass/cli/apps/modules/init_project.py +++ b/src/aipass/cli/apps/modules/init_project.py @@ -21,7 +21,7 @@ import sys from pathlib import Path from typing import List -from aipass.cli.apps.handlers.init.bootstrap import init_project +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 @@ -56,6 +56,11 @@ def print_introspection(): "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() @@ -93,6 +98,7 @@ def print_help(): 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) @@ -159,6 +165,10 @@ def handle_command(command: str, args: List[str]) -> bool: 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 @@ -195,6 +205,10 @@ def _handle_init(args: List[str]) -> bool: _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:]) @@ -307,6 +321,95 @@ def _handle_init_agent(args: List[str]) -> bool: 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 not args or 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, hooks/, 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_arg = args[0] + target = Path(target_arg) + 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") + + 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]Skipped:[/yellow] {len(result['skipped_files'])} files" + ) + console.print(Panel(summary, border_style="green", box=box.ROUNDED)) + + # Updated files table + 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) + + # Skipped files table + skipped_table = Table( + show_header=True, header_style="bold cyan", border_style="dim", title="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() + + success(f"Updated {len(result['updated_files'])} files") + + json_handler.log_operation("aipass_init_update", { + "project_name": result["project_name"], + "target": result["target"], + "files_updated": len(result["updated_files"]), + "files_skipped": len(result["skipped_files"]), + }) + + return True + + def _print_init_help(): """Display detailed help for the init subcommand.""" from rich.panel import Panel @@ -330,6 +433,8 @@ def _print_init_help(): 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) diff --git a/src/aipass/spawn/apps/handlers/registry.py b/src/aipass/spawn/apps/handlers/registry.py index 18739c14..b45a501d 100644 --- a/src/aipass/spawn/apps/handlers/registry.py +++ b/src/aipass/spawn/apps/handlers/registry.py @@ -12,6 +12,7 @@ import os from datetime import datetime from pathlib import Path +from aipass.prax.apps.modules.logger import system_logger as logger from aipass.spawn.apps.handlers.json import json_handler @@ -212,3 +213,57 @@ def add_to_registry(registry_path, branch_name, branch_path, profile, email, pur json_handler.log_operation("registry_updated", data={"branch": branch_name}) return save_registry(registry_path, registry) + + +def fix_passport_registry_id(branch_dir: Path, registry_path: Path) -> bool: + """Update passport.json registry_id if it doesn't match the current registry. + + Call when adopting an existing agent or during sync-registry --fix to repair + registry_id mismatches caused by registry recreation. + + Args: + branch_dir: Path to the branch directory (containing .trinity/passport.json) + registry_path: Path to the project registry (*_REGISTRY.json) + + Returns: + True if passport was updated, False if already correct or failed. + """ + passport_path = branch_dir / ".trinity" / "passport.json" + if not passport_path.exists(): + return False + if not registry_path.exists(): + return False + + try: + registry_data = json_handler.read_json(registry_path) + if registry_data is None: + return False + current_id = registry_data.get("metadata", {}).get("id", "") + except Exception as e: + logger.warning("[registry] Cannot read registry_id from %s: %s", registry_path.name, e) + return False + + if not current_id: + return False + + try: + passport = json_handler.read_json(passport_path) + if passport is None: + return False + old_id = passport.get("citizenship", {}).get("registry_id", "") + if old_id == current_id: + return False # Already correct, no update needed + + passport.setdefault("citizenship", {})["registry_id"] = current_id + success = json_handler.write_json(passport_path, passport) + if success: + logger.info( + "[registry] Fixed registry_id for %s: %s → %s", + branch_dir.name, + old_id[:8] if old_id else "empty", + current_id[:8], + ) + return success + except Exception as e: + logger.warning("[registry] Failed to fix registry_id for %s: %s", branch_dir.name, e) + return False diff --git a/src/aipass/spawn/apps/handlers/sync_registry_ops.py b/src/aipass/spawn/apps/handlers/sync_registry_ops.py index 7c413518..71939f3b 100644 --- a/src/aipass/spawn/apps/handlers/sync_registry_ops.py +++ b/src/aipass/spawn/apps/handlers/sync_registry_ops.py @@ -27,6 +27,7 @@ from aipass.spawn.apps.handlers.registry import ( load_registry, save_registry, _branches_as_list, + fix_passport_registry_id, ) from aipass.spawn.apps.handlers.meta_ops import ( load_template_registry, @@ -243,6 +244,17 @@ def sync_registry(fix: bool = False) -> dict: spawn_rebuilt.append(name) logger.info(f"[sync-registry] Rebuilt .spawn/ for: {name}") + # Fix registry_id mismatches in passports for all known branches + ids_fixed = [] + if fix: + all_known = list(healthy) + list(unregistered_list) + for name in all_known: + branch_path = filesystem_branches.get(name) + if branch_path: + was_fixed = fix_passport_registry_id(branch_path, registry_path) + if was_fixed: + ids_fixed.append(name) + json_handler.log_operation("registry_scanned") return { @@ -251,4 +263,5 @@ def sync_registry(fix: bool = False) -> dict: "healthy": healthy, "fixed": fixed, "spawn_rebuilt": spawn_rebuilt, + "ids_fixed": ids_fixed, } diff --git a/src/aipass/spawn/apps/modules/core.py b/src/aipass/spawn/apps/modules/core.py index d14eeff5..25c2b122 100644 --- a/src/aipass/spawn/apps/modules/core.py +++ b/src/aipass/spawn/apps/modules/core.py @@ -35,7 +35,7 @@ from aipass.spawn.apps.handlers.metadata import get_branch_name, normalize_branc from aipass.spawn.apps.handlers.placeholders import build_replacements_dict, validate_no_placeholders from aipass.spawn.apps.handlers.file_ops import copy_template, rename_placeholder_paths, regenerate_template_registry, ensure_directory from aipass.spawn.apps.handlers.meta_ops import load_template_registry, generate_branch_meta, save_branch_meta -from aipass.spawn.apps.handlers.registry import find_registry, add_to_registry, get_next_citizen_number +from aipass.spawn.apps.handlers.registry import find_registry, add_to_registry, get_next_citizen_number, fix_passport_registry_id from aipass.spawn.apps.handlers.class_registry import ( get_template_dir as _get_template_dir, validate_class as validate_class, @@ -230,9 +230,13 @@ def _spawn_agent(target_path, role="", traits="", purpose="", profile=None, def _adopt_existing(target, purpose, profile, registry_path): """Register an existing directory that already has a passport. - Used when 'aipass init agent' targets a directory the user already + Enhanced to also: + - Fix registry_id mismatch in passport (caused by registry recreation) + - Run template update to sync scaffolding files + + Used when 'spawn create @existing' targets a directory the user already moved code into. Instead of failing with "Target already exists", - we register it in the project registry. + we register it and sync its template files. Args: target: Path to the existing directory with .trinity/passport.json @@ -262,6 +266,9 @@ def _adopt_existing(target, purpose, profile, registry_path): logger.warning("Failed to read passport for purpose: %s", e) purpose = "Adopted agent" + # Fix registry_id in passport if it doesn't match the current registry + fix_passport_registry_id(target, reg_path) + # Store path relative to registry location try: registry_branch_path = str(target.relative_to(reg_path.parent)) @@ -277,11 +284,24 @@ def _adopt_existing(target, purpose, profile, registry_path): json_handler.log_operation("branch_adopted", data={"branch": branch_upper}) logger.info("[spawn] Adopted existing branch: %s (registered in %s)", branch_upper, reg_path.name) + # Run template update to sync scaffolding files. + # Preserves: .trinity/, .ai_mail.local/, memories, all .py files. + # Only adds missing template files and merges JSON configs. + update_additions = 0 + try: + from aipass.spawn.apps.handlers.update_ops import update_branch + update_result = update_branch(branch_lower) + update_additions = update_result.get("additions", 0) + if update_result.get("errors"): + logger.warning("[spawn] Template update had errors for %s: %s", branch_upper, update_result["errors"]) + except Exception as exc: + logger.warning("[spawn] Template update failed for %s (adoption succeeded): %s", branch_upper, exc) + return { "success": True, "branch_name": branch_upper, "path": str(target), - "files_copied": 0, + "files_copied": update_additions, "dirs_created": 0, "files_skipped": 0, "renamed": [], diff --git a/src/aipass/spawn/apps/modules/sync_registry.py b/src/aipass/spawn/apps/modules/sync_registry.py index 70ccf452..e9e5f5d7 100644 --- a/src/aipass/spawn/apps/modules/sync_registry.py +++ b/src/aipass/spawn/apps/modules/sync_registry.py @@ -134,9 +134,12 @@ def _print_summary(result: dict) -> None: console.print(" [green]No unregistered branches[/green]") # Fix status - if fixed: + ids_fixed = result.get("ids_fixed", []) + if fixed or ids_fixed: console.print() console.print(" [green]Registry has been repaired.[/green]") + if ids_fixed: + console.print(f" [green]Fixed registry_id in {len(ids_fixed)} passport(s): {', '.join(sorted(ids_fixed))}[/green]") elif stale or unregistered: console.print() console.print(" [dim]Run with --fix to auto-repair.[/dim]") diff --git a/src/aipass/spawn/tests/test_lifecycle.py b/src/aipass/spawn/tests/test_lifecycle.py index d06ee40a..c263a5e4 100644 --- a/src/aipass/spawn/tests/test_lifecycle.py +++ b/src/aipass/spawn/tests/test_lifecycle.py @@ -525,6 +525,192 @@ class TestAdoptExisting: entry = next(b for b in reg["branches"] if b["name"] == "SMART_AGENT") assert entry["description"] == "Process reports daily" + def test_adopt_existing_fixes_registry_id(self, tmp_path): + """Adoption fixes mismatched registry_id in passport.""" + from aipass.spawn.apps.modules.core import _spawn_agent + + agent = tmp_path / "id_agent" + agent.mkdir() + (agent / ".trinity").mkdir() + (agent / ".trinity" / "passport.json").write_text(json.dumps({ + "branch_info": {"branch_name": "id_agent"}, + "identity": {"citizen_class": "builder", "purpose": "Test"}, + "citizenship": {"registry_id": "old-uuid-1234"}, + })) + + reg_path = tmp_path / "TEST_REGISTRY.json" + reg_path.write_text(json.dumps({ + "metadata": { + "id": "new-uuid-5678", + "version": "1.0.0", + "last_updated": "2026-04-11", + "total_branches": 0, + }, + "branches": [], + })) + + result = _spawn_agent(str(agent), registry_path=str(reg_path)) + + assert result["success"] is True + passport = json.loads((agent / ".trinity" / "passport.json").read_text()) + assert passport["citizenship"]["registry_id"] == "new-uuid-5678" + + def test_adopt_skips_registry_id_fix_when_already_correct(self, tmp_path): + """Adoption does not modify passport when registry_id already matches.""" + from aipass.spawn.apps.modules.core import _spawn_agent + + agent = tmp_path / "matched_agent" + agent.mkdir() + (agent / ".trinity").mkdir() + (agent / ".trinity" / "passport.json").write_text(json.dumps({ + "branch_info": {"branch_name": "matched_agent"}, + "identity": {"citizen_class": "builder", "purpose": "Test"}, + "citizenship": {"registry_id": "correct-uuid-9999"}, + })) + + reg_path = tmp_path / "TEST_REGISTRY.json" + reg_path.write_text(json.dumps({ + "metadata": { + "id": "correct-uuid-9999", + "version": "1.0.0", + "last_updated": "2026-04-11", + "total_branches": 0, + }, + "branches": [], + })) + + # Get mtime before adoption to detect writes + before_mtime = (agent / ".trinity" / "passport.json").stat().st_mtime + + result = _spawn_agent(str(agent), registry_path=str(reg_path)) + + assert result["success"] is True + after_mtime = (agent / ".trinity" / "passport.json").stat().st_mtime + # File should NOT have been rewritten (ids already match) + assert before_mtime == after_mtime + + +# --------------------------------------------------------------------------- +# FIX PASSPORT REGISTRY ID Tests +# --------------------------------------------------------------------------- + +class TestFixPassportRegistryId: + """Tests for fix_passport_registry_id() in registry.py.""" + + def test_fixes_mismatched_id(self, tmp_path): + """Updates passport when registry_id doesn't match.""" + from aipass.spawn.apps.handlers.registry import fix_passport_registry_id + + branch = tmp_path / "myagent" + branch.mkdir() + (branch / ".trinity").mkdir() + (branch / ".trinity" / "passport.json").write_text(json.dumps({ + "citizenship": {"registry_id": "old-id"}, + })) + + reg = tmp_path / "TEST_REGISTRY.json" + reg.write_text(json.dumps({"metadata": {"id": "new-id"}, "branches": []})) + + result = fix_passport_registry_id(branch, reg) + + assert result is True + passport = json.loads((branch / ".trinity" / "passport.json").read_text()) + assert passport["citizenship"]["registry_id"] == "new-id" + + def test_skips_when_already_correct(self, tmp_path): + """Returns False when ids already match (no write needed).""" + from aipass.spawn.apps.handlers.registry import fix_passport_registry_id + + branch = tmp_path / "myagent" + branch.mkdir() + (branch / ".trinity").mkdir() + (branch / ".trinity" / "passport.json").write_text(json.dumps({ + "citizenship": {"registry_id": "same-id"}, + })) + + reg = tmp_path / "TEST_REGISTRY.json" + reg.write_text(json.dumps({"metadata": {"id": "same-id"}, "branches": []})) + + result = fix_passport_registry_id(branch, reg) + + assert result is False + + def test_handles_missing_passport(self, tmp_path): + """Returns False gracefully when passport doesn't exist.""" + from aipass.spawn.apps.handlers.registry import fix_passport_registry_id + + branch = tmp_path / "npassport" + branch.mkdir() + + reg = tmp_path / "TEST_REGISTRY.json" + reg.write_text(json.dumps({"metadata": {"id": "some-id"}, "branches": []})) + + result = fix_passport_registry_id(branch, reg) + assert result is False + + def test_handles_registry_with_no_id(self, tmp_path): + """Returns False when registry has no metadata.id.""" + from aipass.spawn.apps.handlers.registry import fix_passport_registry_id + + branch = tmp_path / "myagent" + branch.mkdir() + (branch / ".trinity").mkdir() + (branch / ".trinity" / "passport.json").write_text(json.dumps({ + "citizenship": {"registry_id": "old-id"}, + })) + + reg = tmp_path / "TEST_REGISTRY.json" + reg.write_text(json.dumps({"metadata": {}, "branches": []})) # No id field + + result = fix_passport_registry_id(branch, reg) + assert result is False + + def test_sync_registry_fix_repairs_ids(self, tmp_path, monkeypatch): + """sync_registry --fix calls fix_passport_registry_id on healthy branches.""" + from aipass.spawn.apps.handlers.sync_registry_ops import sync_registry + + # Set up a project with one healthy branch that has wrong registry_id + project = tmp_path / "project" + project.mkdir() + + reg_path = project / "TEST_REGISTRY.json" + reg_path.write_text(json.dumps({ + "metadata": { + "id": "correct-uuid-abc", + "version": "1.0.0", + "last_updated": "2026-04-11", + "total_branches": 1, + }, + "branches": [ + { + "name": "MYAGENT", + "path": "myagent", + "profile": "library", + "description": "Test", + "email": "@myagent", + "status": "active", + "created": "2026-04-11", + "last_active": "2026-04-11", + } + ], + })) + + agent_dir = project / "myagent" + agent_dir.mkdir() + (agent_dir / ".trinity").mkdir() + passport_path = agent_dir / ".trinity" / "passport.json" + passport_path.write_text(json.dumps({ + "citizenship": {"registry_id": "old-stale-uuid"}, + })) + + monkeypatch.chdir(project) + + result = sync_registry(fix=True) + + assert "myagent" in result.get("ids_fixed", []) + passport = json.loads(passport_path.read_text()) + assert passport["citizenship"]["registry_id"] == "correct-uuid-abc" + # --------------------------------------------------------------------------- # SYNC TEMPLATES Tests