Merge pull request #248 from AIOSAI/system/devpulse-s87-model-choice-for-dispatch-dplan-0114-aipass-in

feat(system): S87: model choice for dispatch + DPLAN-0114 (aipass init update) + Compass decisions + Patrick-Personal deny rules
This commit is contained in:
AIPass
2026-04-11 01:52:33 -07:00
committed by GitHub
11 changed files with 602 additions and 23 deletions
+7 -1
View File
@@ -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"
},
@@ -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)
+27 -8
View File
@@ -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())
+25
View File
@@ -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-'"
+119 -4
View File
@@ -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 ``<!-- Source: {file_path} -->`` as the first line.
"""
return f"<!-- Source: {file_path} -->\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,
}
+106 -1
View File
@@ -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 <name>[/green] [dim]Create an agent in current project[/dim]")
console.print(" [green]drone @cli aipass init update[/green] [dim]Refresh managed scaffold files[/dim]")
console.print(" [green]drone @cli aipass --help[/green] [dim]This help message[/dim]")
console.print()
console.print("─" * 70)
@@ -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 <name>' 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)
@@ -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
@@ -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,
}
+24 -4
View File
@@ -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": [],
@@ -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]")
+186
View File
@@ -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