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