From 1252bf5b9dc0407da10f2e87496ef01937999220 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Wed, 8 Apr 2026 21:38:19 -0700 Subject: [PATCH 1/2] feat(system): S80: CLI init double-prefix fix + watchdog auto-start prompt Co-Authored-By: @devpulse --- .../cli/apps/handlers/init/bootstrap.py | 133 +++++++++++++++--- src/aipass/cli/apps/modules/init_project.py | 17 ++- .../devpulse/.aipass/aipass_local_prompt.md | 1 + 3 files changed, 128 insertions(+), 23 deletions(-) diff --git a/src/aipass/cli/apps/handlers/init/bootstrap.py b/src/aipass/cli/apps/handlers/init/bootstrap.py index 3d297839..7ad92bbe 100644 --- a/src/aipass/cli/apps/handlers/init/bootstrap.py +++ b/src/aipass/cli/apps/handlers/init/bootstrap.py @@ -10,12 +10,14 @@ Init Bootstrap Handler - PRIVATE implementation Business logic for `aipass init`. Creates the project scaffold: - 1. {NAME}_REGISTRY.json — project registry with UUID - 2. .trinity/passport.json — project identity - 3. .trinity/local.json — local context (empty) - 4. .trinity/observations.json — observations (empty) + 1. {NAME}_REGISTRY.json — project registry with UUID + 2. .trinity/passport.json — project identity + 3. .trinity/local.json — session history (full schema) + 4. .trinity/observations.json — collaboration patterns (full schema) 5. .aipass/aipass_local_prompt.md — local prompt - 6. AIPASS.md — project prompt + 6. CLAUDE.md — project prompt (Claude Code reads this) + 7. README.md — project readme + 8. STATUS.local.md — project status RULES: - Pure Python only (no module/prax/cli imports) @@ -127,12 +129,80 @@ def init_project(target: Path, project_name: str | None = None) -> dict: ) created.append(str(passport_path)) - # local.json + observations.json - for filename in ("local.json", "observations.json"): - p = trinity_dir / filename - if not p.exists(): - p.write_text("{}\n", encoding="utf-8") - created.append(str(p)) + # local.json — session history with full schema + local_path = trinity_dir / "local.json" + if not local_path.exists(): + local_data = { + "document_metadata": { + "document_type": "session_history", + "document_name": f"{name}.LOCAL", + "version": "1.0.0", + "schema_version": "1.0.0", + "created": today, + "last_updated": today, + "managed_by": name.lower(), + "tags": ["session_tracking", "work_log"], + "limits": { + "max_sessions": 20, + "max_key_learnings": 25, + "session_summary_max_chars": 150, + "learning_value_max_chars": 200, + "note": "Auto-rollover when max_lines exceeded", + "max_lines": 600, + }, + "status": { + "health": "healthy", + "last_health_check": today, + "current_lines": 0, + }, + }, + "key_learnings": {}, + "sessions": [], + "active_tasks": { + "today_focus": today, + "recently_completed": [], + }, + } + local_path.write_text( + json.dumps(local_data, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + created.append(str(local_path)) + + # observations.json — collaboration patterns with full schema + obs_path = trinity_dir / "observations.json" + if not obs_path.exists(): + obs_data = { + "document_metadata": { + "document_type": "collaboration_patterns", + "document_name": f"{name}.OBSERVATIONS", + "version": "1.0.0", + "schema_version": "1.0.0", + "created": today, + "last_updated": today, + "managed_by": name.lower(), + "tags": ["collaboration", "patterns"], + "limits": { + "max_lines": 600, + "note": "Auto-rollover when max_lines exceeded", + }, + "status": { + "health": "healthy", + "current_lines": 0, + "last_health_check": today, + }, + }, + "guidelines": { + "purpose": "Capture collaboration patterns and experiential insights over time", + "chronological_order": "Newest entries at TOP, oldest at BOTTOM - NEVER reorder", + }, + "observations": [], + } + obs_path.write_text( + json.dumps(obs_data, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + created.append(str(obs_path)) # 3. .aipass/ aipass_dir = target / ".aipass" @@ -146,24 +216,49 @@ def init_project(target: Path, project_name: str | None = None) -> dict: ) created.append(str(prompt_path)) - # 4. AIPASS.md - aipass_md = target / "AIPASS.md" - if not aipass_md.exists(): - aipass_md.write_text( - "# AIPass — Project Prompt\n\n" - "Project-level instructions. Persists in context for the entire conversation.\n\n" + # 4. CLAUDE.md (project prompt — Claude Code reads this by default) + claude_md = target / "CLAUDE.md" + if not claude_md.exists(): + claude_md.write_text( + f"# {name}\n\n" + "**User:** Name\n\n" "## Startup\n\n" "On any greeting, silently read these files from CWD and run the commands " "— no narration, no announcing steps. Just do it and respond with the status.\n\n" "**Read:** `.trinity/passport.json`, `.trinity/local.json`, " - "`.trinity/observations.json`, `README.md`\n" + "`.trinity/observations.json`, `README.md`, `STATUS.local.md`\n" "**Run:** `git status`\n\n" "## Memories\n\n" "Update `.trinity/` at natural breakpoints, after milestones, and on `/memo`. " "If compaction hits before you save, it's gone.\n", encoding="utf-8", ) - created.append(str(aipass_md)) + created.append(str(claude_md)) + + # 5. README.md + readme_md = target / "README.md" + if not readme_md.exists(): + readme_md.write_text( + f"# {name}\n\n" + "## Getting Started\n\n" + "This project was initialized with `aipass init`.\n", + encoding="utf-8", + ) + created.append(str(readme_md)) + + # 6. STATUS.local.md + status_md = target / "STATUS.local.md" + if not status_md.exists(): + status_md.write_text( + f"# {name}\n\n" + "**State:** New\n" + f"**Last update:** {today}\n\n" + "## Current Work\n\n" + "## Known Issues\n" + "- None\n", + encoding="utf-8", + ) + created.append(str(status_md)) return { "registry_id": registry_id, diff --git a/src/aipass/cli/apps/modules/init_project.py b/src/aipass/cli/apps/modules/init_project.py index bc7afab0..672cc12b 100644 --- a/src/aipass/cli/apps/modules/init_project.py +++ b/src/aipass/cli/apps/modules/init_project.py @@ -95,14 +95,16 @@ def print_help(): console.print("[bold cyan]WHAT INIT CREATES:[/bold cyan]") console.print() - files_text = """[bold]Project scaffold (6 files):[/bold] + files_text = """[bold]Project scaffold (8 files):[/bold] [green]1.[/green] [yellow]{NAME}_REGISTRY.json[/yellow] Project registry with UUID [green]2.[/green] [yellow].trinity/passport.json[/yellow] Project identity (with registry_id) [green]3.[/green] [yellow].trinity/local.json[/yellow] Session history & learnings [green]4.[/green] [yellow].trinity/observations.json[/yellow] Collaboration patterns [green]5.[/green] [yellow].aipass/aipass_local_prompt.md[/yellow] Local prompt (injected every turn) - [green]6.[/green] [yellow]AIPASS.md[/yellow] Project prompt (persists in context)""" + [green]6.[/green] [yellow]CLAUDE.md[/yellow] Project prompt (Claude Code reads this) + [green]7.[/green] [yellow]README.md[/yellow] Project readme + [green]8.[/green] [yellow]STATUS.local.md[/yellow] Project status""" console.print(Panel(files_text, border_style="green", padding=(1, 2), box=box.ROUNDED)) console.print() @@ -143,6 +145,11 @@ def handle_command(command: str, args: List[str]) -> bool: Returns: True if handled, False if not this module's command """ + # Direct subcommand: 'aipass init /path' on PATH → command="init" + if command == "init": + return _handle_init(args) + + # Prefixed command: 'drone @cli aipass init' → command="aipass" if command != "aipass": return False @@ -266,14 +273,16 @@ def _print_init_help(): console.print("[bold cyan]WHAT IT CREATES:[/bold cyan]") console.print() - files_text = """[bold]Project scaffold (6 files):[/bold] + files_text = """[bold]Project scaffold (8 files):[/bold] [green]1.[/green] [yellow]{NAME}_REGISTRY.json[/yellow] Project registry with UUID [green]2.[/green] [yellow].trinity/passport.json[/yellow] Project identity (with registry_id) [green]3.[/green] [yellow].trinity/local.json[/yellow] Session history & learnings [green]4.[/green] [yellow].trinity/observations.json[/yellow] Collaboration patterns [green]5.[/green] [yellow].aipass/aipass_local_prompt.md[/yellow] Local prompt (injected every turn) - [green]6.[/green] [yellow]AIPASS.md[/yellow] Project prompt (persists in context)""" + [green]6.[/green] [yellow]CLAUDE.md[/yellow] Project prompt (Claude Code reads this) + [green]7.[/green] [yellow]README.md[/yellow] Project readme + [green]8.[/green] [yellow]STATUS.local.md[/yellow] Project status""" console.print(Panel(files_text, border_style="green", padding=(1, 2), box=box.ROUNDED)) console.print() diff --git a/src/aipass/devpulse/.aipass/aipass_local_prompt.md b/src/aipass/devpulse/.aipass/aipass_local_prompt.md index 717ea52c..1d12ded2 100644 --- a/src/aipass/devpulse/.aipass/aipass_local_prompt.md +++ b/src/aipass/devpulse/.aipass/aipass_local_prompt.md @@ -13,6 +13,7 @@ You are DEVPULSE — orchestration hub. Manager, not builder. Coordinate, plan, - Never block waiting on agents. Never burn context reading code across branches. - Use `drone @branch --help` for command syntax. Use `drone systems` for branch list. - **ALWAYS WAKE after sending dispatch emails.** Send email → wake. Every time. No asking. If the user wants something different, they will say so. +- **START WATCHDOG after any dispatch.** Run `python3 src/aipass/prax/tools/inbox_watchdog.py src/aipass/devpulse/.ai_mail.local/inbox.json --interval 30 &` after dispatching. Don't wait for Patrick to ask. ## Dispatch, Don't Do From f92e4a06caa3255c343a64774dad717d0696af20 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Wed, 8 Apr 2026 23:17:21 -0700 Subject: [PATCH 2/2] =?UTF-8?q?feat(system):=20S81:=20aipass=20init=20v2?= =?UTF-8?q?=20=E2=80=94=20real=20templates,=20spawn=20routing,=20README=20?= =?UTF-8?q?Quick=20Start?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: @devpulse --- README.md | 64 +- assets/aipass-logo.svg | 17 - .../cli/apps/handlers/init/bootstrap.py | 562 ++++++++++++------ src/aipass/cli/apps/modules/init_project.py | 103 +++- src/aipass/cli/tests/test_bootstrap.py | 240 +++++--- .../drone/apps/handlers/routing_config.json | 5 + src/aipass/spawn/apps/modules/core.py | 6 +- src/aipass/spawn/apps/spawn.py | 10 +- .../builder/.spawn/.template_registry.json | 470 +++++++-------- .../spawn/tests/test_citizen_classes.py | 103 ++++ src/aipass/spawn/tests/test_cli_routing.py | 48 ++ 11 files changed, 1042 insertions(+), 586 deletions(-) delete mode 100644 assets/aipass-logo.svg diff --git a/README.md b/README.md index d9851131..efcc3de3 100644 --- a/README.md +++ b/README.md @@ -48,20 +48,23 @@ AIPass is a local CLI framework that gives your AI agents **identity, memory, an Your AI reads `.trinity/` on startup and writes back what it learned before the session ends. That's the whole memory model — JSON files your AI can read and write. Next session, it picks up where it left off. No database, no API, no setup beyond one command. ```bash -aipass init ~/Projects/my-saas-app +mkdir my-project && cd my-project +aipass init ``` Your project gets its own registry, its own identity, and persistent memory. Each project is isolated — its own agents, its own rules. No cross-contamination between projects. -**Add teammates when you need them:** +**Add agents when you need them:** -When one agent isn't enough, use `spawn` to create specialists with the same infrastructure AIPass runs on — communication, monitoring, standards, the whole scaffold. +```bash +aipass init agent my-agent # Full agent: apps, mail, memory, identity +``` -| Start here | What to use | What you get | -|------------|-------------|-------------| -| One persistent agent | `aipass init` | Registry, passport, memory files, local prompt | -| A lightweight specialist | `spawn passport` | Identity + rich memory (no apps scaffold) | -| A full specialist | `spawn create` | All of the above + apps scaffold, mail, dashboard, tests | +| What you need | Command | What you get | +|---------------|---------|-------------| +| A new project | `aipass init` | Registry, project identity, prompts, hooks, docs | +| A full agent | `aipass init agent ` | Apps scaffold, mailbox, memory, identity — registered in project | +| A lightweight agent | `drone @spawn create --template birthright` | Identity + memory only (no apps scaffold) | **What makes this different:** @@ -77,45 +80,36 @@ When one agent isn't enough, use `spawn` to create specialists with the same inf ## Quick Start +### Start your own project + ```bash -pip install aipass # Install the package +pip install aipass + +mkdir my-project && cd my-project +aipass init # Creates project: registry, prompts, hooks, docs +aipass init agent my-agent # Creates your first agent inside the project +cd my-agent +claude # Or: codex, gemini — your agent reads its memory and is ready ``` -Or clone the full framework with all 15 agents: +That's it. Your agent has identity, memory, a mailbox, and knows what AIPass is. Say "hi" — it picks up where it left off. Come back tomorrow, it remembers. + +### Explore the full framework + +Clone the repo to see all 15 agents working together — the reference implementation: ```bash git clone https://github.com/AIOSAI/AIPass.git cd AIPass -./setup.sh # Creates venv, installs, bootstraps 15 agents -drone systems # See all agents -``` +./setup.sh # Creates venv, installs, bootstraps 15 agents +drone systems # See all agents -Then start working: - -```bash cd src/aipass/devpulse -claude # devpulse reads its memory, knows who it is, picks up where it left off +claude # Talk to the orchestrator ``` -Say "hi." Here's what that looks like: - -``` -You: hi -devpulse: Hey. Picking up where we left off. - - Status: - - Branch: main, up to date - - Inbox: 1 email from drone — routing fix applied - - Git: 3 files modified, not committed - - Dropbox: 1 item from @api - - Ready when you are. -``` - -Come back tomorrow — it remembers. - ```bash -# More things you can do: +# Things you can do: drone @seedgo audit aipass # Run 33 quality checks across all agents drone @flow create . "Add user auth" # Create a work plan drone systems # List every agent and what it does diff --git a/assets/aipass-logo.svg b/assets/aipass-logo.svg deleted file mode 100644 index f62973b3..00000000 --- a/assets/aipass-logo.svg +++ /dev/null @@ -1,17 +0,0 @@ - - AIPass logo - A passport-inspired pass badge with a routed A monogram. - - - - - - - - - - - - - - diff --git a/src/aipass/cli/apps/handlers/init/bootstrap.py b/src/aipass/cli/apps/handlers/init/bootstrap.py index 7ad92bbe..2aa32c46 100644 --- a/src/aipass/cli/apps/handlers/init/bootstrap.py +++ b/src/aipass/cli/apps/handlers/init/bootstrap.py @@ -1,23 +1,29 @@ # =================== AIPass ==================== # Name: bootstrap.py # Description: Init handler — bootstrap an AIPass project in any directory -# Version: 1.0.0 +# Version: 2.0.0 # Created: 2026-03-14 -# Modified: 2026-03-14 +# Modified: 2026-04-08 # ============================================= """ Init Bootstrap Handler - PRIVATE implementation Business logic for `aipass init`. Creates the project scaffold: - 1. {NAME}_REGISTRY.json — project registry with UUID - 2. .trinity/passport.json — project identity - 3. .trinity/local.json — session history (full schema) - 4. .trinity/observations.json — collaboration patterns (full schema) - 5. .aipass/aipass_local_prompt.md — local prompt - 6. CLAUDE.md — project prompt (Claude Code reads this) - 7. README.md — project readme - 8. STATUS.local.md — project status + 1. {NAME}_REGISTRY.json — project registry with UUID + 2. .aipass/aipass_global_prompt.md — global prompt (injected every turn) + 3. .aipass/aipass_local_prompt.md — local prompt skeleton + 4. CLAUDE.md — project prompt (Claude Code reads this) + 5. AGENTS.md — Codex equivalent of CLAUDE.md + 6. GEMINI.md — Gemini equivalent of CLAUDE.md + 7. README.md — getting started guide + 8. STATUS.local.md — project status + 9. .gitignore — standard AIPass ignores + 10. .claude/settings.json — Claude Code hooks configuration + 11. hooks/ — directory for user hooks + +Projects are NOT citizens — no .trinity/ directory. Identity lives in the +registry JSON. Init is re-runnable: existing files are skipped, not errors. RULES: - Pure Python only (no module/prax/cli imports) @@ -41,6 +47,311 @@ def _sanitize_name(raw: str) -> str: return re.sub(r"[^A-Z0-9_-]", "_", raw.upper()).strip("_") +# --------------------------------------------------------------------------- +# Template content generators +# --------------------------------------------------------------------------- + + +def _claude_md(name: str) -> str: + """Generate CLAUDE.md content — Claude Code reads this on startup.""" + return ( + f"# {name}\n" + "\n" + "**User:** (your name here)\n" + "\n" + "## What is AIPass\n" + "\n" + "AIPass is a multi-agent framework. This project was created with `aipass init`.\n" + "\n" + "**Key concepts:**\n" + "- **Project** — this directory. Contains a registry and one or more agents.\n" + "- **Agent** — a citizen that lives inside the project. Has identity (`.trinity/`), " + "memory, mailbox, and its own apps/ directory.\n" + f"- **Registry** — `{name}_REGISTRY.json` tracks all agents in this project.\n" + "\n" + "## Getting Started\n" + "\n" + "Create your first agent:\n" + "```\n" + "aipass init agent \n" + "```\n" + "\n" + "This creates a full agent scaffold (`apps/`, `.trinity/`, `.ai_mail.local/`) " + "and registers it in your project registry.\n" + "\n" + "## Available Commands\n" + "\n" + "```\n" + "aipass init agent # Create a new agent\n" + "drone @spawn create # Create agent (alternative)\n" + "drone @seedgo audit # Run standards audit\n" + "drone @ai_mail inbox # Check mailbox (per-agent)\n" + "drone systems # List all available infrastructure\n" + "```\n" + "\n" + "## Startup Protocol\n" + "\n" + "On any greeting, silently read these files — no narration, just do it " + "and respond with the status.\n" + "\n" + f"**Read:** `{name}_REGISTRY.json`, `README.md`, `STATUS.local.md`\n" + "**Run:** `git status`\n" + "\n" + "Then check the registry for agents and report status.\n" + ) + + +def _agents_md(name: str) -> str: + """Generate AGENTS.md content — Codex equivalent of CLAUDE.md.""" + return ( + f"# {name} — Agent Instructions\n" + "\n" + "This project uses AIPass, a multi-agent framework.\n" + "\n" + "## Key Concepts\n" + "\n" + "- **Project** — this directory. Contains a registry and one or more agents.\n" + "- **Agent** — a citizen that lives inside the project with its own identity, " + "memory, and code.\n" + f"- **Registry** — `{name}_REGISTRY.json` tracks all agents.\n" + "\n" + "## Getting Started\n" + "\n" + "Create your first agent:\n" + "```\n" + "aipass init agent \n" + "```\n" + "\n" + "## Available Commands\n" + "\n" + "```\n" + "aipass init agent # Create a new agent\n" + "drone @spawn create # Create agent (alternative)\n" + "drone @seedgo audit # Run standards audit\n" + "drone systems # List all infrastructure\n" + "```\n" + "\n" + "## Startup\n" + "\n" + f"On startup, read: `{name}_REGISTRY.json`, `README.md`, `STATUS.local.md`\n" + ) + + +def _gemini_md(name: str) -> str: + """Generate GEMINI.md content — Gemini equivalent of CLAUDE.md.""" + return ( + f"# {name} — Project Instructions\n" + "\n" + "This project uses AIPass, a multi-agent framework.\n" + "\n" + "## Key Concepts\n" + "\n" + "- **Project** — this directory. Contains a registry and one or more agents.\n" + "- **Agent** — a citizen that lives inside the project with its own identity, " + "memory, and code.\n" + f"- **Registry** — `{name}_REGISTRY.json` tracks all agents.\n" + "\n" + "## Getting Started\n" + "\n" + "Create your first agent: `aipass init agent `\n" + "\n" + "## Available Commands\n" + "\n" + "```\n" + "aipass init agent # Create a new agent\n" + "drone @spawn create # Create agent (alternative)\n" + "drone @seedgo audit # Run standards audit\n" + "drone systems # List all infrastructure\n" + "```\n" + "\n" + "## Startup\n" + "\n" + f"On startup, read: `{name}_REGISTRY.json`, `README.md`, `STATUS.local.md`\n" + ) + + +def _readme_md(name: str) -> str: + """Generate README.md content — real getting started guide.""" + return ( + f"# {name}\n" + "\n" + "An AIPass project.\n" + "\n" + "## Quick Start\n" + "\n" + "```bash\n" + "# 1. Create your first agent\n" + "aipass init agent my_agent\n" + "\n" + "# 2. Start a session\n" + "cd my_agent/\n" + "claude # or your preferred AI CLI\n" + "\n" + "# 3. Check project status\n" + "cat STATUS.local.md\n" + "```\n" + "\n" + "## Project Structure\n" + "\n" + "```\n" + f"{name.lower()}/\n" + f" {name}_REGISTRY.json # Agent registry\n" + " .aipass/ # Prompts (injected per-turn)\n" + " CLAUDE.md # Claude Code instructions\n" + " AGENTS.md # Codex instructions\n" + " GEMINI.md # Gemini instructions\n" + " STATUS.local.md # Project status\n" + " / # Agent directories (created via aipass init agent)\n" + "```\n" + "\n" + "## What is AIPass?\n" + "\n" + "AIPass is a multi-agent framework where autonomous agents (citizens) " + "live in directories with persistent identity, memory, and communication.\n" + "\n" + "Each agent has:\n" + "- **Identity** — `.trinity/passport.json`\n" + "- **Memory** — `.trinity/local.json`, `observations.json`\n" + "- **Mailbox** — `.ai_mail.local/`\n" + "- **Code** — `apps/` with modules and handlers\n" + "\n" + "## Commands\n" + "\n" + "| Command | Description |\n" + "|---------|-------------|\n" + "| `aipass init agent ` | Create a new agent |\n" + "| `drone @spawn create ` | Create agent (alternative) |\n" + "| `drone @seedgo audit ` | Run standards audit |\n" + "| `drone @ai_mail inbox` | Check agent mailbox |\n" + "| `drone systems` | List infrastructure |\n" + "\n" + f"*Initialized with [AIPass](https://github.com/AIOSAI/AIPass) on " + f"{{date}}*\n" + ) + + +def _global_prompt_md(name: str) -> str: + """Generate .aipass/aipass_global_prompt.md — injected every turn.""" + return ( + f"# {name} — Project Context\n" + "\n" + "\n" + "## Terminology\n" + "\n" + "- **Project** — this directory. Contains a registry and agents.\n" + "- **Agent** — a citizen with identity (`.trinity/`), memory, mailbox, " + "and code (`apps/`).\n" + f"- **Registry** — `{name}_REGISTRY.json` tracks all agents.\n" + "\n" + "## Commands\n" + "\n" + "```\n" + "aipass init agent # Create a new agent\n" + "drone @spawn create # Create agent (alternative)\n" + "drone @seedgo audit # Standards audit\n" + "drone @ai_mail inbox # Check mailbox\n" + "drone @flow create . \"Subject\" # Create a plan\n" + "drone systems # List infrastructure\n" + "```\n" + "\n" + "## Patterns\n" + "\n" + "- **Communication** — agents communicate via `.ai_mail.local/`.\n" + "- **Standards** — run `drone @seedgo audit` to check compliance.\n" + "- **Identity** — agents have `.trinity/passport.json`. " + "Projects use the registry.\n" + ) + + +def _local_prompt_md(name: str) -> str: + """Generate .aipass/aipass_local_prompt.md — starter skeleton.""" + return ( + f"# {name} — Local Prompt\n" + "\n" + "\n" + "## Project Identity\n" + "\n" + f"- **Name:** {name}\n" + "- **Purpose:** (describe your project)\n" + "- **Status:** New\n" + "\n" + "## How You Work\n" + "\n" + "- Read the registry and STATUS.local.md on startup for context\n" + "- Check the registry for active agents\n" + "\n" + "## Key Files\n" + "\n" + f"- `{name}_REGISTRY.json` — agent registry\n" + "- `STATUS.local.md` — current status\n" + ) + + +def _gitignore() -> str: + """Generate .gitignore content — standard AIPass ignores.""" + return ( + "# AIPass local state\n" + ".trinity/\n" + ".ai_mail.local/\n" + "*.local.*\n" + "\n" + "# Plans (local working docs)\n" + "DPLAN-*\n" + "FPLAN-*\n" + "APLAN-*\n" + "TDPLAN-*\n" + "\n" + "# Logs\n" + "logs/\n" + "\n" + "# Python\n" + "__pycache__/\n" + "*.py[cod]\n" + "*.egg-info/\n" + "dist/\n" + "build/\n" + ".venv/\n" + "venv/\n" + "\n" + "# IDE\n" + ".vscode/\n" + ".idea/\n" + "\n" + "# OS\n" + ".DS_Store\n" + "Thumbs.db\n" + "\n" + "# Archives\n" + ".archive/\n" + "\n" + "# Disabled files\n" + "*(disabled)*\n" + ) + + +def _claude_settings() -> str: + """Generate .claude/settings.json — minimal hooks for prompt injection.""" + return json.dumps( + { + "hooks": { + "UserPromptSubmit": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "cat .aipass/aipass_global_prompt.md 2>/dev/null || true", + } + ], + } + ] + } + }, + indent=2, + ensure_ascii=False, + ) + "\n" + + def init_project(target: Path, project_name: str | None = None) -> dict: """Initialize an AIPass project in the target directory. @@ -53,7 +364,6 @@ def init_project(target: Path, project_name: str | None = None) -> dict: Raises: ValueError: If project name is empty after sanitization - FileExistsError: If passport or registry already exists """ target = target.resolve() if not target.exists(): @@ -71,185 +381,74 @@ def init_project(target: Path, project_name: str | None = None) -> dict: today = date.today().isoformat() created = [] - # 1. Registry + # 1. Registry (skip if exists — init is re-runnable) registry_filename = f"{name}_REGISTRY.json" registry_path = target / registry_filename if registry_path.exists(): - raise FileExistsError(f"Registry already exists: {registry_path}") - - registry_data = { - "metadata": { - "id": registry_id, - "name": name, - "version": "1.0.0", - "created": today, - "last_updated": today, - "total_branches": 0, - }, - "branches": [], - } - registry_path.write_text( - json.dumps(registry_data, indent=2, ensure_ascii=False) + "\n", - encoding="utf-8", - ) - created.append(str(registry_path)) - - # 2. .trinity/ - trinity_dir = target / ".trinity" - trinity_dir.mkdir(exist_ok=True) - - passport = { - "document_metadata": { - "document_type": "project_identity", - "document_name": f"{name}.PASSPORT", - "version": "1.0.0", - "created": today, - "last_updated": today, - }, - "identity": { - "project_name": name, - "role": "project_root", - "purpose": "", - }, - "citizenship": { - "registered": True, - "registry_id": registry_id, - "registry_name": name, - }, - } - passport_path = trinity_dir / "passport.json" - if passport_path.exists(): - raise FileExistsError( - f"Passport already exists: {passport_path}. " - "Remove .trinity/passport.json to re-initialize." - ) - passport_path.write_text( - json.dumps(passport, indent=2, ensure_ascii=False) + "\n", - encoding="utf-8", - ) - created.append(str(passport_path)) - - # local.json — session history with full schema - local_path = trinity_dir / "local.json" - if not local_path.exists(): - local_data = { - "document_metadata": { - "document_type": "session_history", - "document_name": f"{name}.LOCAL", + # Read existing registry to get its ID + existing = json.loads(registry_path.read_text(encoding="utf-8")) + registry_id = existing["metadata"]["id"] + else: + registry_data = { + "metadata": { + "id": registry_id, + "name": name, "version": "1.0.0", - "schema_version": "1.0.0", "created": today, "last_updated": today, - "managed_by": name.lower(), - "tags": ["session_tracking", "work_log"], - "limits": { - "max_sessions": 20, - "max_key_learnings": 25, - "session_summary_max_chars": 150, - "learning_value_max_chars": 200, - "note": "Auto-rollover when max_lines exceeded", - "max_lines": 600, - }, - "status": { - "health": "healthy", - "last_health_check": today, - "current_lines": 0, - }, - }, - "key_learnings": {}, - "sessions": [], - "active_tasks": { - "today_focus": today, - "recently_completed": [], + "total_branches": 0, }, + "branches": [], } - local_path.write_text( - json.dumps(local_data, indent=2, ensure_ascii=False) + "\n", + registry_path.write_text( + json.dumps(registry_data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", ) - created.append(str(local_path)) + created.append(str(registry_path)) - # observations.json — collaboration patterns with full schema - obs_path = trinity_dir / "observations.json" - if not obs_path.exists(): - obs_data = { - "document_metadata": { - "document_type": "collaboration_patterns", - "document_name": f"{name}.OBSERVATIONS", - "version": "1.0.0", - "schema_version": "1.0.0", - "created": today, - "last_updated": today, - "managed_by": name.lower(), - "tags": ["collaboration", "patterns"], - "limits": { - "max_lines": 600, - "note": "Auto-rollover when max_lines exceeded", - }, - "status": { - "health": "healthy", - "current_lines": 0, - "last_health_check": today, - }, - }, - "guidelines": { - "purpose": "Capture collaboration patterns and experiential insights over time", - "chronological_order": "Newest entries at TOP, oldest at BOTTOM - NEVER reorder", - }, - "observations": [], - } - obs_path.write_text( - json.dumps(obs_data, indent=2, ensure_ascii=False) + "\n", - encoding="utf-8", - ) - created.append(str(obs_path)) - - # 3. .aipass/ + # 2. .aipass/ aipass_dir = target / ".aipass" aipass_dir.mkdir(exist_ok=True) + global_prompt_path = aipass_dir / "aipass_global_prompt.md" + if not global_prompt_path.exists(): + global_prompt_path.write_text(_global_prompt_md(name), encoding="utf-8") + created.append(str(global_prompt_path)) + prompt_path = aipass_dir / "aipass_local_prompt.md" if not prompt_path.exists(): - prompt_path.write_text( - f"# {name} — Local Prompt\n\nInjected every turn. Add project-specific context here.\n", - encoding="utf-8", - ) + prompt_path.write_text(_local_prompt_md(name), encoding="utf-8") created.append(str(prompt_path)) - # 4. CLAUDE.md (project prompt — Claude Code reads this by default) - claude_md = target / "CLAUDE.md" - if not claude_md.exists(): - claude_md.write_text( - f"# {name}\n\n" - "**User:** Name\n\n" - "## Startup\n\n" - "On any greeting, silently read these files from CWD and run the commands " - "— no narration, no announcing steps. Just do it and respond with the status.\n\n" - "**Read:** `.trinity/passport.json`, `.trinity/local.json`, " - "`.trinity/observations.json`, `README.md`, `STATUS.local.md`\n" - "**Run:** `git status`\n\n" - "## Memories\n\n" - "Update `.trinity/` at natural breakpoints, after milestones, and on `/memo`. " - "If compaction hits before you save, it's gone.\n", - encoding="utf-8", - ) - created.append(str(claude_md)) + # 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") + created.append(str(claude_md_path)) - # 5. README.md - readme_md = target / "README.md" - if not readme_md.exists(): - readme_md.write_text( - f"# {name}\n\n" - "## Getting Started\n\n" - "This project was initialized with `aipass init`.\n", - encoding="utf-8", - ) - created.append(str(readme_md)) + # 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") + created.append(str(agents_md_path)) - # 6. STATUS.local.md - status_md = target / "STATUS.local.md" - if not status_md.exists(): - status_md.write_text( + # 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") + created.append(str(gemini_md_path)) + + # 6. README.md + readme_md_path = target / "README.md" + if not readme_md_path.exists(): + readme_content = _readme_md(name).replace("{date}", today) + readme_md_path.write_text(readme_content, encoding="utf-8") + created.append(str(readme_md_path)) + + # 7. STATUS.local.md + status_md_path = target / "STATUS.local.md" + if not status_md_path.exists(): + status_md_path.write_text( f"# {name}\n\n" "**State:** New\n" f"**Last update:** {today}\n\n" @@ -258,7 +457,28 @@ def init_project(target: Path, project_name: str | None = None) -> dict: "- None\n", encoding="utf-8", ) - created.append(str(status_md)) + created.append(str(status_md_path)) + + # 8. .gitignore + gitignore_path = target / ".gitignore" + if not gitignore_path.exists(): + gitignore_path.write_text(_gitignore(), encoding="utf-8") + created.append(str(gitignore_path)) + + # 9. .claude/settings.json + claude_dir = target / ".claude" + claude_dir.mkdir(exist_ok=True) + + settings_path = claude_dir / "settings.json" + if not settings_path.exists(): + settings_path.write_text(_claude_settings(), encoding="utf-8") + created.append(str(settings_path)) + + # 10. hooks/ directory + hooks_dir = target / "hooks" + if not hooks_dir.exists(): + hooks_dir.mkdir() + created.append(str(hooks_dir)) return { "registry_id": registry_id, diff --git a/src/aipass/cli/apps/modules/init_project.py b/src/aipass/cli/apps/modules/init_project.py index 672cc12b..848d497a 100644 --- a/src/aipass/cli/apps/modules/init_project.py +++ b/src/aipass/cli/apps/modules/init_project.py @@ -16,6 +16,7 @@ Run: drone @cli aipass """ import os +import subprocess import sys from pathlib import Path from typing import List @@ -50,6 +51,11 @@ def print_introspection(): "Bootstrap a new AIPass project", "drone @cli aipass init /path MyProject", ) + table.add_row( + "init agent", + "Create an agent in current project", + "drone @cli aipass init agent my_agent", + ) console.print(table) console.print() @@ -86,6 +92,7 @@ def print_help(): console.print(" [green]drone @cli aipass init[/green] [dim]Bootstrap in current directory[/dim]") console.print(" [green]drone @cli aipass init /path[/green] [dim]Bootstrap in target directory[/dim]") console.print(" [green]drone @cli aipass init /path MyProj[/green] [dim]Bootstrap with custom name[/dim]") + console.print(" [green]drone @cli aipass init agent [/green] [dim]Create an agent in current project[/dim]") console.print(" [green]drone @cli aipass --help[/green] [dim]This help message[/dim]") console.print() console.print("─" * 70) @@ -95,16 +102,19 @@ def print_help(): console.print("[bold cyan]WHAT INIT CREATES:[/bold cyan]") console.print() - files_text = """[bold]Project scaffold (8 files):[/bold] + files_text = """[bold]Project scaffold (11 items):[/bold] - [green]1.[/green] [yellow]{NAME}_REGISTRY.json[/yellow] Project registry with UUID - [green]2.[/green] [yellow].trinity/passport.json[/yellow] Project identity (with registry_id) - [green]3.[/green] [yellow].trinity/local.json[/yellow] Session history & learnings - [green]4.[/green] [yellow].trinity/observations.json[/yellow] Collaboration patterns - [green]5.[/green] [yellow].aipass/aipass_local_prompt.md[/yellow] Local prompt (injected every turn) - [green]6.[/green] [yellow]CLAUDE.md[/yellow] Project prompt (Claude Code reads this) - [green]7.[/green] [yellow]README.md[/yellow] Project readme - [green]8.[/green] [yellow]STATUS.local.md[/yellow] Project status""" + [green]1.[/green] [yellow]{NAME}_REGISTRY.json[/yellow] Project registry with UUID + [green]2.[/green] [yellow].aipass/aipass_global_prompt.md[/yellow] Global prompt (injected every turn) + [green]3.[/green] [yellow].aipass/aipass_local_prompt.md[/yellow] Local prompt skeleton + [green]4.[/green] [yellow]CLAUDE.md[/yellow] Project prompt (Claude Code) + [green]5.[/green] [yellow]AGENTS.md[/yellow] Codex instructions + [green]6.[/green] [yellow]GEMINI.md[/yellow] Gemini instructions + [green]7.[/green] [yellow]README.md[/yellow] Getting started guide + [green]8.[/green] [yellow]STATUS.local.md[/yellow] Project status + [green]9.[/green] [yellow].gitignore[/yellow] Standard AIPass ignores + [green]10.[/green] [yellow].claude/settings.json[/yellow] Claude Code hooks + [green]11.[/green] [yellow]hooks/[/yellow] User hooks directory""" console.print(Panel(files_text, border_style="green", padding=(1, 2), box=box.ROUNDED)) console.print() @@ -185,6 +195,10 @@ def _handle_init(args: List[str]) -> bool: _print_init_help() return True + # Route 'init agent ' to spawn + if args and args[0] == "agent": + return _handle_init_agent(args[1:]) + # Parse positional args: [target_dir] [project_name] caller_cwd = os.environ.get("AIPASS_CALLER_CWD", os.getcwd()) target = Path(args[0]) if args else Path(caller_cwd) @@ -198,7 +212,7 @@ def _handle_init(args: List[str]) -> bool: sys.exit(1) except FileExistsError as exc: logger.warning("Init target already exists: %s", exc) - error(str(exc), suggestion="Remove the existing file to re-initialize") + error(str(exc)) sys.exit(1) except OSError as exc: logger.error("Init filesystem error: %s", exc) @@ -236,11 +250,59 @@ def _handle_init(args: List[str]) -> bool: "files_created": len(result["created_files"]), }) + # Next steps + console.print() + console.print("[bold cyan]Next steps:[/bold cyan]") + console.print(f" [green]1.[/green] Create your first agent: [yellow]aipass init agent [/yellow]") + console.print(f" [green]2.[/green] Start a session: [dim]cd / && claude[/dim]") + console.print(f" [green]3.[/green] Read the docs: [dim]cat README.md[/dim]") console.print() return True +def _handle_init_agent(args: List[str]) -> bool: + """Handle 'aipass init agent ' — routes to drone @spawn create.""" + if not args or args[0] in ("--help", "-h", "help"): + console.print() + header("aipass init agent — Create an Agent") + console.print("[dim]Creates a new agent inside the current project[/dim]") + console.print() + console.print("[bold cyan]Usage:[/bold cyan]") + console.print(" [green]aipass init agent [/green]") + console.print(" [green]drone @cli aipass init agent [/green]") + console.print() + console.print("[bold cyan]What it does:[/bold cyan]") + console.print(" Routes to [yellow]drone @spawn create [/yellow]") + console.print(" Creates full agent scaffold (apps/, .trinity/, .ai_mail.local/)") + console.print(" Registers agent in the project registry") + console.print() + console.print("[dim]Commands: init agent, init agent --help[/dim]") + console.print() + return True + + agent_name = args[0] + logger.info("Routing 'init agent %s' to drone @spawn create", agent_name) + + try: + result = subprocess.run( + ["drone", "@spawn", "create", agent_name], + check=False, + ) + if result.returncode != 0: + error( + f"Agent creation failed (exit {result.returncode})", + suggestion="Check drone @spawn create --help", + ) + return True + except FileNotFoundError: + error( + "drone command not found", + suggestion="Ensure AIPass is installed and drone is in PATH", + ) + return True + + def _print_init_help(): """Display detailed help for the init subcommand.""" from rich.panel import Panel @@ -273,16 +335,19 @@ def _print_init_help(): console.print("[bold cyan]WHAT IT CREATES:[/bold cyan]") console.print() - files_text = """[bold]Project scaffold (8 files):[/bold] + files_text = """[bold]Project scaffold (11 items):[/bold] - [green]1.[/green] [yellow]{NAME}_REGISTRY.json[/yellow] Project registry with UUID - [green]2.[/green] [yellow].trinity/passport.json[/yellow] Project identity (with registry_id) - [green]3.[/green] [yellow].trinity/local.json[/yellow] Session history & learnings - [green]4.[/green] [yellow].trinity/observations.json[/yellow] Collaboration patterns - [green]5.[/green] [yellow].aipass/aipass_local_prompt.md[/yellow] Local prompt (injected every turn) - [green]6.[/green] [yellow]CLAUDE.md[/yellow] Project prompt (Claude Code reads this) - [green]7.[/green] [yellow]README.md[/yellow] Project readme - [green]8.[/green] [yellow]STATUS.local.md[/yellow] Project status""" + [green]1.[/green] [yellow]{NAME}_REGISTRY.json[/yellow] Project registry with UUID + [green]2.[/green] [yellow].aipass/aipass_global_prompt.md[/yellow] Global prompt (injected every turn) + [green]3.[/green] [yellow].aipass/aipass_local_prompt.md[/yellow] Local prompt skeleton + [green]4.[/green] [yellow]CLAUDE.md[/yellow] Project prompt (Claude Code) + [green]5.[/green] [yellow]AGENTS.md[/yellow] Codex instructions + [green]6.[/green] [yellow]GEMINI.md[/yellow] Gemini instructions + [green]7.[/green] [yellow]README.md[/yellow] Getting started guide + [green]8.[/green] [yellow]STATUS.local.md[/yellow] Project status + [green]9.[/green] [yellow].gitignore[/yellow] Standard AIPass ignores + [green]10.[/green] [yellow].claude/settings.json[/yellow] Claude Code hooks + [green]11.[/green] [yellow]hooks/[/yellow] User hooks directory""" console.print(Panel(files_text, border_style="green", padding=(1, 2), box=box.ROUNDED)) console.print() diff --git a/src/aipass/cli/tests/test_bootstrap.py b/src/aipass/cli/tests/test_bootstrap.py index 6d072696..0a010bac 100644 --- a/src/aipass/cli/tests/test_bootstrap.py +++ b/src/aipass/cli/tests/test_bootstrap.py @@ -5,7 +5,6 @@ use tmp_path to stay fully isolated from the live filesystem. """ import json -import re import uuid from datetime import date from pathlib import Path @@ -75,8 +74,8 @@ def test_sanitize_name_only_underscores(): # --------------------------------------------------------------------------- -def test_init_project_creates_all_six_files(tmp_path): - """init_project produces exactly the six expected files.""" +def test_init_project_creates_all_expected_files(tmp_path): + """init_project produces all expected files and directories.""" target = tmp_path / "proj" target.mkdir() @@ -84,16 +83,27 @@ def test_init_project_creates_all_six_files(tmp_path): expected_files = [ target / "DEMO_REGISTRY.json", - target / ".trinity" / "passport.json", - target / ".trinity" / "local.json", - target / ".trinity" / "observations.json", + target / ".aipass" / "aipass_global_prompt.md", target / ".aipass" / "aipass_local_prompt.md", - target / "AIPASS.md", + target / "CLAUDE.md", + target / "AGENTS.md", + target / "GEMINI.md", + target / "README.md", + target / "STATUS.local.md", + target / ".gitignore", + target / ".claude" / "settings.json", ] for f in expected_files: assert f.exists(), f"Expected file not created: {f}" - assert len(result["created_files"]) == 6 + # hooks/ is a directory, not a file + assert (target / "hooks").is_dir(), "Expected hooks/ directory" + + # No .trinity/ should be created (projects are not citizens) + assert not (target / ".trinity").exists(), ".trinity/ should NOT be created" + + # 10 files + 1 directory = 11 created_files entries + assert len(result["created_files"]) == 11 def test_init_project_return_dict_structure(tmp_path): @@ -146,38 +156,14 @@ def test_init_project_registry_json_contents(tmp_path): assert data["branches"] == [] -def test_init_project_passport_json_contents(tmp_path): - """passport.json has correct identity and citizenship fields.""" +def test_init_project_no_trinity_created(tmp_path): + """Projects are not citizens — no .trinity/ directory created.""" target = tmp_path / "proj" target.mkdir() - result = init_project(target, project_name="gamma") + init_project(target, project_name="gamma") - passport_path = target / ".trinity" / "passport.json" - data = json.loads(passport_path.read_text(encoding="utf-8")) - - assert data["document_metadata"]["document_type"] == "project_identity" - assert data["document_metadata"]["document_name"] == "GAMMA.PASSPORT" - assert data["document_metadata"]["version"] == "1.0.0" - assert re.match(r"^\d{4}-\d{2}-\d{2}$", data["document_metadata"]["created"]) - assert data["identity"]["project_name"] == "GAMMA" - assert data["identity"]["role"] == "project_root" - assert data["citizenship"]["registered"] is True - assert data["citizenship"]["registry_id"] == result["registry_id"] - assert data["citizenship"]["registry_name"] == "GAMMA" - - -def test_init_project_local_and_observations_are_empty_objects(tmp_path): - """local.json and observations.json are written as empty JSON objects.""" - target = tmp_path / "proj" - target.mkdir() - - init_project(target, project_name="delta") - - for filename in ("local.json", "observations.json"): - path = target / ".trinity" / filename - content = path.read_text(encoding="utf-8") - assert content == "{}\n" + assert not (target / ".trinity").exists() def test_init_project_local_prompt_content(tmp_path): @@ -193,41 +179,40 @@ def test_init_project_local_prompt_content(tmp_path): assert "Local Prompt" in content -def test_init_project_aipass_md_content(tmp_path): - """AIPASS.md contains the standard project prompt boilerplate.""" +def test_init_project_claude_md_content(tmp_path): + """CLAUDE.md contains real AIPass project content.""" target = tmp_path / "proj" target.mkdir() init_project(target, project_name="zeta") - md_path = target / "AIPASS.md" + md_path = target / "CLAUDE.md" content = md_path.read_text(encoding="utf-8") - assert "# AIPass" in content - assert "## Startup" in content - assert "## Memories" in content - assert ".trinity/passport.json" in content + assert "# ZETA" in content + assert "## What is AIPass" in content + assert "## Getting Started" in content + assert "## Available Commands" in content + assert "## Startup Protocol" in content + # Startup protocol reads registry, not .trinity/ + startup_idx = content.index("## Startup Protocol") + startup_section = content[startup_idx:] + assert ".trinity/" not in startup_section + assert "aipass init agent" in content + assert "ZETA_REGISTRY.json" in content -def test_init_project_raises_on_existing_passport(tmp_path): - """FileExistsError when .trinity/passport.json already exists.""" +def test_init_project_rerunnable_skips_existing_registry(tmp_path): + """Running init twice skips the existing registry and reuses its ID.""" target = tmp_path / "proj" target.mkdir() - trinity = target / ".trinity" - trinity.mkdir() - (trinity / "passport.json").write_text("{}", encoding="utf-8") - with pytest.raises(FileExistsError, match="Passport already exists"): - init_project(target, project_name="dup") + result1 = init_project(target, project_name="dup") + result2 = init_project(target, project_name="dup") - -def test_init_project_raises_on_existing_registry(tmp_path): - """FileExistsError when the REGISTRY.json file already exists.""" - target = tmp_path / "proj" - target.mkdir() - (target / "DUP_REGISTRY.json").write_text("{}", encoding="utf-8") - - with pytest.raises(FileExistsError, match="Registry already exists"): - init_project(target, project_name="dup") + # Same registry ID reused + assert result1["registry_id"] == result2["registry_id"] + # Second run creates no new files (all already exist) + assert len(result2["created_files"]) == 0 def test_init_project_raises_on_empty_name(tmp_path): @@ -239,6 +224,84 @@ def test_init_project_raises_on_empty_name(tmp_path): init_project(target, project_name="!!!") +def test_init_project_agents_md_content(tmp_path): + """AGENTS.md contains Codex-equivalent content.""" + target = tmp_path / "proj" + target.mkdir() + + init_project(target, project_name="alpha") + + content = (target / "AGENTS.md").read_text(encoding="utf-8") + assert "# ALPHA" in content + assert "ALPHA_REGISTRY.json" in content + assert "aipass init agent" in content + + +def test_init_project_gemini_md_content(tmp_path): + """GEMINI.md contains Gemini-equivalent content.""" + target = tmp_path / "proj" + target.mkdir() + + init_project(target, project_name="alpha") + + content = (target / "GEMINI.md").read_text(encoding="utf-8") + assert "# ALPHA" in content + assert "ALPHA_REGISTRY.json" in content + + +def test_init_project_gitignore_content(tmp_path): + """.gitignore contains standard AIPass patterns.""" + target = tmp_path / "proj" + target.mkdir() + + init_project(target, project_name="alpha") + + content = (target / ".gitignore").read_text(encoding="utf-8") + assert ".trinity/" in content + assert ".ai_mail.local/" in content + assert "__pycache__/" in content + assert "logs/" in content + + +def test_init_project_claude_settings_content(tmp_path): + """.claude/settings.json has valid hook configuration.""" + target = tmp_path / "proj" + target.mkdir() + + init_project(target, project_name="alpha") + + settings_path = target / ".claude" / "settings.json" + data = json.loads(settings_path.read_text(encoding="utf-8")) + assert "hooks" in data + assert "UserPromptSubmit" in data["hooks"] + + +def test_init_project_global_prompt_content(tmp_path): + """Global prompt contains project name and AIPass terminology.""" + target = tmp_path / "proj" + target.mkdir() + + init_project(target, project_name="alpha") + + content = (target / ".aipass" / "aipass_global_prompt.md").read_text(encoding="utf-8") + assert "# ALPHA" in content + assert "ALPHA_REGISTRY.json" in content + assert "## Commands" in content + + +def test_init_project_readme_md_content(tmp_path): + """README.md contains getting started guide with project name.""" + target = tmp_path / "proj" + target.mkdir() + + init_project(target, project_name="alpha") + + content = (target / "README.md").read_text(encoding="utf-8") + assert "# ALPHA" in content + assert "## Quick Start" in content + assert "aipass init agent" in content + + def test_init_project_auto_creates_target_dir(tmp_path): """Target directory is created (including parents) if it doesn't exist.""" target = tmp_path / "deep" / "nested" / "proj" @@ -248,7 +311,7 @@ def test_init_project_auto_creates_target_dir(tmp_path): assert target.is_dir() assert result["project_name"] == "NESTED" - assert len(result["created_files"]) == 6 + assert len(result["created_files"]) == 11 def test_init_project_defaults_name_from_directory(tmp_path): @@ -275,47 +338,48 @@ def test_init_project_custom_name_overrides_directory(tmp_path): def test_init_project_skips_existing_optional_files(tmp_path): - """local.json, observations.json, prompt, and AIPASS.md are not - overwritten if they already exist (only passport and registry guard - with errors).""" + """Optional files are not overwritten if they already exist. + Init is re-runnable — existing files are skipped.""" target = tmp_path / "proj" target.mkdir() - # Pre-create the optional files - trinity = target / ".trinity" - trinity.mkdir() - (trinity / "local.json").write_text('{"existing": true}\n', encoding="utf-8") - (trinity / "observations.json").write_text( - '{"existing": true}\n', encoding="utf-8" - ) - + # Pre-create optional files aipass_dir = target / ".aipass" aipass_dir.mkdir() + (aipass_dir / "aipass_global_prompt.md").write_text( + "# Custom global\n", encoding="utf-8" + ) (aipass_dir / "aipass_local_prompt.md").write_text( "# Custom prompt\n", encoding="utf-8" ) - (target / "AIPASS.md").write_text("# Custom AIPASS\n", encoding="utf-8") + (target / "CLAUDE.md").write_text("# Custom CLAUDE\n", encoding="utf-8") + (target / "AGENTS.md").write_text("# Custom AGENTS\n", encoding="utf-8") + (target / "GEMINI.md").write_text("# Custom GEMINI\n", encoding="utf-8") + (target / "README.md").write_text("# Custom README\n", encoding="utf-8") + (target / "STATUS.local.md").write_text("# Custom status\n", encoding="utf-8") + (target / ".gitignore").write_text("# Custom\n", encoding="utf-8") + + claude_dir = target / ".claude" + claude_dir.mkdir() + (claude_dir / "settings.json").write_text("{}\n", encoding="utf-8") + + hooks_dir = target / "hooks" + hooks_dir.mkdir() result = init_project(target, project_name="eta") - # Only registry and passport should be in created_files - assert len(result["created_files"]) == 2 + # Only registry should be in created_files (everything else pre-existed) + assert len(result["created_files"]) == 1 # Verify pre-existing files were NOT overwritten - local_content = (trinity / "local.json").read_text(encoding="utf-8") - assert '"existing": true' in local_content - - obs_content = (trinity / "observations.json").read_text(encoding="utf-8") - assert '"existing": true' in obs_content - prompt_content = (aipass_dir / "aipass_local_prompt.md").read_text( encoding="utf-8" ) assert prompt_content == "# Custom prompt\n" - md_content = (target / "AIPASS.md").read_text(encoding="utf-8") - assert md_content == "# Custom AIPASS\n" + md_content = (target / "CLAUDE.md").read_text(encoding="utf-8") + assert md_content == "# Custom CLAUDE\n" def test_init_project_returns_dict(tmp_path): @@ -328,15 +392,13 @@ def test_init_project_returns_dict(tmp_path): assert isinstance(result, dict) -def test_init_project_no_overwrite_local_json(tmp_path): - """Existing local.json content is preserved — no_overwrite contract.""" +def test_init_project_agents_md_no_trinity(tmp_path): + """AGENTS.md startup protocol references registry, not .trinity/.""" target = tmp_path / "proj" target.mkdir() - trinity = target / ".trinity" - trinity.mkdir() - (trinity / "local.json").write_text('{"custom": true}\n', encoding="utf-8") init_project(target, project_name="keep") - content = (trinity / "local.json").read_text(encoding="utf-8") - assert '"custom": true' in content + content = (target / "AGENTS.md").read_text(encoding="utf-8") + assert ".trinity/" not in content + assert "KEEP_REGISTRY.json" in content diff --git a/src/aipass/drone/apps/handlers/routing_config.json b/src/aipass/drone/apps/handlers/routing_config.json index 2655514b..7495ff24 100644 --- a/src/aipass/drone/apps/handlers/routing_config.json +++ b/src/aipass/drone/apps/handlers/routing_config.json @@ -9,6 +9,11 @@ "entry_point": "aipass.cli.apps.cli", "description": "Universal Display & Output Service Provider", "version": "2.0.0" + }, + "spawn": { + "entry_point": "aipass.spawn.apps.spawn", + "description": "Branch lifecycle — create, register, scaffold citizens", + "version": "1.0.0" } } } diff --git a/src/aipass/spawn/apps/modules/core.py b/src/aipass/spawn/apps/modules/core.py index 204a120c..18f8fdf6 100644 --- a/src/aipass/spawn/apps/modules/core.py +++ b/src/aipass/spawn/apps/modules/core.py @@ -103,7 +103,11 @@ def handle_command(command: str, args: List[str]) -> bool: kwargs["purpose"] = args[i + 1] i += 2 elif args[i] == "--template" and i + 1 < len(args): - kwargs["template_dir"] = args[i + 1] + template_val = args[i + 1] + if validate_class(template_val): + kwargs["citizen_class"] = template_val + else: + kwargs["template_dir"] = template_val i += 2 elif args[i] == "--registry" and i + 1 < len(args): kwargs["registry_path"] = args[i + 1] diff --git a/src/aipass/spawn/apps/spawn.py b/src/aipass/spawn/apps/spawn.py index bfc4fb7f..896aebae 100644 --- a/src/aipass/spawn/apps/spawn.py +++ b/src/aipass/spawn/apps/spawn.py @@ -55,7 +55,7 @@ def print_help(): warning("--role", details="Agent role description") warning("--traits", details="Agent personality traits") warning("--purpose", details="Agent purpose (brief)") - warning("--template", details="Custom template directory") + warning("--template", details="Template class name (builder, birthright) or custom directory path") warning("--registry", details="Path to AIPASS_REGISTRY.json") warning("--dry-run", details="Preview changes without modifying files") warning("--trace", details="Enable verbose logging") @@ -100,6 +100,12 @@ def handle_create(args): parsed = parser.parse_args(remaining_args) + # --template can be a class name (e.g. "builder") or a raw path + template_dir = parsed.template + if parsed.template and validate_class(parsed.template): + citizen_class = parsed.template + template_dir = None + if dry_run: return _dry_run_create(parsed.target_path, citizen_class, parsed) @@ -108,7 +114,7 @@ def handle_create(args): role=parsed.role, traits=parsed.traits, purpose=parsed.purpose, - template_dir=parsed.template, + template_dir=template_dir, registry_path=parsed.registry, citizen_class=citizen_class, ) diff --git a/src/aipass/spawn/templates/builder/.spawn/.template_registry.json b/src/aipass/spawn/templates/builder/.spawn/.template_registry.json index b782377b..270a0093 100644 --- a/src/aipass/spawn/templates/builder/.spawn/.template_registry.json +++ b/src/aipass/spawn/templates/builder/.spawn/.template_registry.json @@ -1,384 +1,350 @@ { "metadata": { - "version": "1.0.0", - "last_updated": "2026-04-07", - "description": "Template file tracking registry for ID-based updates" + "description": "Template registry for tracking files", + "generated": true }, "files": { - "f002": { + "f001": { "path": ".ai_mail.local/README.md", "name": "README.md", - "content_hash": "49299c242a01", - "has_branch_placeholder": false + "content_hash": "49299c242a018d73", + "has_branch_placeholder": true }, - "f001": { + "f002": { "path": ".ai_mail.local/inbox.json", "name": "inbox.json", - "content_hash": "c9702fe2cc21", - "has_branch_placeholder": false - }, - "f015": { - "path": ".aipass/README.md", - "name": "README.md", - "content_hash": "f42d87684fdf", + "content_hash": "c9702fe2cc21b748", "has_branch_placeholder": false }, "f003": { - "path": ".aipass/aipass_local_prompt.md", - "name": "aipass_local_prompt.md", - "content_hash": "5f8e2a3859a9", - "has_branch_placeholder": false + "path": ".aipass/README.md", + "name": "README.md", + "content_hash": "f42d87684fdfb1af", + "has_branch_placeholder": true }, "f004": { - "path": ".archive/README.md", - "name": "README.md", - "content_hash": "93d3fcb74f23", - "has_branch_placeholder": false - }, - "f025": { - "path": ".claude/README.md", - "name": "README.md", - "content_hash": "adb0ce8c53c1", - "has_branch_placeholder": false + "path": ".aipass/aipass_local_prompt.md", + "name": "aipass_local_prompt.md", + "content_hash": "5f8e2a3859a99906", + "has_branch_placeholder": true }, "f005": { - "path": ".claude/settings.local.json", - "name": "settings.local.json", - "content_hash": "eacf065629cd", - "has_branch_placeholder": false + "path": ".archive/README.md", + "name": "README.md", + "content_hash": "93d3fcb74f234b1d", + "has_branch_placeholder": true }, "f006": { - "path": ".gitignore", - "name": ".gitignore", - "content_hash": "841dedb922da", - "has_branch_placeholder": false - }, - "f027": { - "path": ".seedgo/README.md", + "path": ".claude/README.md", "name": "README.md", - "content_hash": "ea03468bbf16", - "has_branch_placeholder": false + "content_hash": "adb0ce8c53c1da2d", + "has_branch_placeholder": true }, "f007": { - "path": ".seedgo/bypass.json", - "name": "bypass.json", - "content_hash": "0ac90a35515b", - "has_branch_placeholder": false - }, - "f016": { - "path": ".spawn/.registry_ignore.json", - "name": ".registry_ignore.json", - "content_hash": "34f5e7ff7e01", - "has_branch_placeholder": false - }, - "f042": { - "path": ".spawn/README.md", - "name": "README.md", - "content_hash": "e22ad5337efd", - "has_branch_placeholder": false - }, - "f028": { - "path": ".trinity/README.md", - "name": "README.md", - "content_hash": "f461c9b16fc5", + "path": ".claude/settings.local.json", + "name": "settings.local.json", + "content_hash": "eacf065629cd9c03", "has_branch_placeholder": false }, "f008": { - "path": ".trinity/local.json", - "name": "local.json", - "content_hash": "f4e62d2d12ba", + "path": ".gitignore", + "name": ".gitignore", + "content_hash": "841dedb922da7ddb", "has_branch_placeholder": false }, "f009": { - "path": ".trinity/observations.json", - "name": "observations.json", - "content_hash": "25b957960a59", - "has_branch_placeholder": false + "path": ".seedgo/README.md", + "name": "README.md", + "content_hash": "ea03468bbf16f248", + "has_branch_placeholder": true }, "f010": { - "path": ".trinity/passport.json", - "name": "passport.json", - "content_hash": "04c2ead2a428", - "has_branch_placeholder": false + "path": ".seedgo/bypass.json", + "name": "bypass.json", + "content_hash": "0ac90a35515b35c2", + "has_branch_placeholder": true }, "f011": { - "path": "DASHBOARD.local.json", - "name": "DASHBOARD.local.json", - "content_hash": "88360d943d4b", - "has_branch_placeholder": false + "path": ".trinity/README.md", + "name": "README.md", + "content_hash": "f461c9b16fc566a0", + "has_branch_placeholder": true }, "f012": { - "path": "README.md", - "name": "README.md", - "content_hash": "ad99517a50f9", - "has_branch_placeholder": false + "path": ".trinity/local.json", + "name": "local.json", + "content_hash": "f4e62d2d12bada2e", + "has_branch_placeholder": true }, "f013": { - "path": "STATUS.local.md", - "name": "STATUS.local.md", - "content_hash": "059295ad4d4e", - "has_branch_placeholder": false - }, - "f029": { - "path": "apps/README.md", - "name": "README.md", - "content_hash": "92a956009e0e", - "has_branch_placeholder": false + "path": ".trinity/observations.json", + "name": "observations.json", + "content_hash": "25b957960a5939a3", + "has_branch_placeholder": true }, "f014": { - "path": "apps/__init__.py", - "name": "__init__.py", - "content_hash": "41b011f487af", - "has_branch_placeholder": false + "path": ".trinity/passport.json", + "name": "passport.json", + "content_hash": "04c2ead2a4284027", + "has_branch_placeholder": true }, - "f030": { - "path": "apps/handlers/README.md", - "name": "README.md", - "content_hash": "2e4f4a0c1b47", - "has_branch_placeholder": false + "f015": { + "path": "CLAUDE.md", + "name": "CLAUDE.md", + "content_hash": "e24c75348c4faa57", + "has_branch_placeholder": true + }, + "f016": { + "path": "DASHBOARD.local.json", + "name": "DASHBOARD.local.json", + "content_hash": "88360d943d4bacb6", + "has_branch_placeholder": true }, "f017": { - "path": "apps/handlers/__init__.py", - "name": "__init__.py", - "content_hash": "55afc5eddbe0", - "has_branch_placeholder": false - }, - "f031": { - "path": "apps/modules/README.md", + "path": "README.md", "name": "README.md", - "content_hash": "a4cf0a8e3b4f", - "has_branch_placeholder": false - }, - "f043": { - "path": "apps/modules/__init__.py", - "name": "__init__.py", - "content_hash": "e3b0c44298fc", - "has_branch_placeholder": false - }, - "f032": { - "path": "apps/plugins/README.md", - "name": "README.md", - "content_hash": "d1e4e2b98c38", - "has_branch_placeholder": false + "content_hash": "ad99517a50f96ba7", + "has_branch_placeholder": true }, "f018": { + "path": "STATUS.local.md", + "name": "STATUS.local.md", + "content_hash": "059295ad4d4e62eb", + "has_branch_placeholder": true + }, + "f019": { + "path": "apps/README.md", + "name": "README.md", + "content_hash": "92a956009e0e22dc", + "has_branch_placeholder": true + }, + "f020": { + "path": "apps/__init__.py", + "name": "__init__.py", + "content_hash": "41b011f487af14dc", + "has_branch_placeholder": true + }, + "f021": { + "path": "apps/handlers/README.md", + "name": "README.md", + "content_hash": "2e4f4a0c1b474ffa", + "has_branch_placeholder": true + }, + "f022": { + "path": "apps/handlers/__init__.py", + "name": "__init__.py", + "content_hash": "55afc5eddbe04134", + "has_branch_placeholder": true + }, + "f023": { + "path": "apps/modules/README.md", + "name": "README.md", + "content_hash": "a4cf0a8e3b4ff993", + "has_branch_placeholder": true + }, + "f024": { + "path": "apps/modules/__init__.py", + "name": "__init__.py", + "content_hash": "e3b0c44298fc1c14", + "has_branch_placeholder": false + }, + "f025": { + "path": "apps/plugins/README.md", + "name": "README.md", + "content_hash": "d1e4e2b98c384ca2", + "has_branch_placeholder": true + }, + "f026": { + "path": "apps/plugins/__init__.py", + "name": "__init__.py", + "content_hash": "e3b0c44298fc1c14", + "has_branch_placeholder": false + }, + "f027": { "path": "apps/{{BRANCH}}.py", "name": "{{BRANCH}}.py", - "content_hash": "fc71b424c10b", + "content_hash": "fc71b424c10bb172", + "has_branch_placeholder": true + }, + "f028": { + "path": "artifacts/README.md", + "name": "README.md", + "content_hash": "de20d11e5cfd3586", + "has_branch_placeholder": true + }, + "f029": { + "path": "artifacts/birth_certificate.json", + "name": "birth_certificate.json", + "content_hash": "1ec401f4e397eec7", + "has_branch_placeholder": true + }, + "f030": { + "path": "docs/README.md", + "name": "README.md", + "content_hash": "2434da568727499d", + "has_branch_placeholder": true + }, + "f031": { + "path": "docs.local/README.md", + "name": "README.md", + "content_hash": "c19d8872ea2c2c25", + "has_branch_placeholder": true + }, + "f032": { + "path": "docs.local/sub_agent_drops/README.md", + "name": "README.md", + "content_hash": "e3e5a6b9c9c51817", + "has_branch_placeholder": false + }, + "f033": { + "path": "dropbox/README.md", + "name": "README.md", + "content_hash": "9e1e9b71f93b4cde", "has_branch_placeholder": true }, "f034": { - "path": "artifacts/README.md", + "path": "logs/README.md", "name": "README.md", - "content_hash": "de20d11e5cfd", - "has_branch_placeholder": false - }, - "f019": { - "path": "artifacts/birth_certificate.json", - "name": "birth_certificate.json", - "content_hash": "1ec401f4e397", - "has_branch_placeholder": false - }, - "f020": { - "path": "docs/README.md", - "name": "README.md", - "content_hash": "2434da568727", - "has_branch_placeholder": false + "content_hash": "4ca207af6bd3a648", + "has_branch_placeholder": true }, "f035": { - "path": "docs.local/README.md", - "name": "README.md", - "content_hash": "c19d8872ea2c", + "path": "pytest.ini", + "name": "pytest.ini", + "content_hash": "7b39ba7bca4025c5", "has_branch_placeholder": false }, "f036": { - "path": "docs.local/sub_agent_drops/README.md", - "name": "README.md", - "content_hash": "e3e5a6b9c9c5", - "has_branch_placeholder": false - }, - "f021": { - "path": "dropbox/README.md", - "name": "README.md", - "content_hash": "9e1e9b71f93b", - "has_branch_placeholder": false - }, - "f037": { - "path": "logs/README.md", - "name": "README.md", - "content_hash": "4ca207af6bd3", - "has_branch_placeholder": false - }, - "f022": { - "path": "pytest.ini", - "name": "pytest.ini", - "content_hash": "7b39ba7bca40", - "has_branch_placeholder": false - }, - "f038": { "path": "templates/README.md", "name": "README.md", - "content_hash": "73b020b003f9", - "has_branch_placeholder": false + "content_hash": "73b020b003f900e5", + "has_branch_placeholder": true }, - "f039": { + "f037": { "path": "tests/README.md", "name": "README.md", - "content_hash": "c157895c9b27", - "has_branch_placeholder": false + "content_hash": "c157895c9b27d3ba", + "has_branch_placeholder": true }, - "f023": { + "f038": { "path": "tests/__init__.py", "name": "__init__.py", - "content_hash": "881f06bb6574", - "has_branch_placeholder": false + "content_hash": "881f06bb6574d2f7", + "has_branch_placeholder": true }, - "f024": { + "f039": { "path": "tests/conftest.py", "name": "conftest.py", - "content_hash": "5d98b049957b", + "content_hash": "5d98b049957b24a4", "has_branch_placeholder": false }, "f040": { "path": "tools/README.md", "name": "README.md", - "content_hash": "3c7eaedb16ac", - "has_branch_placeholder": false - }, - "f026": { - "path": "{{BRANCH}}_json/README.md", - "name": "README.md", - "content_hash": "e64fa555e7b8", - "has_branch_placeholder": false + "content_hash": "3c7eaedb16ac2695", + "has_branch_placeholder": true }, "f041": { + "path": "{{BRANCH}}_json/README.md", + "name": "README.md", + "content_hash": "e64fa555e7b8e20f", + "has_branch_placeholder": true + }, + "f042": { "path": "{{BRANCH}}_json/custom_config/README.md", "name": "README.md", - "content_hash": "28e9ae373563", - "has_branch_placeholder": false - }, - "f033": { - "path": "apps/plugins/__init__.py", - "name": "__init__.py", - "content_hash": "e3b0c44298fc", - "has_branch_placeholder": false + "content_hash": "28e9ae3735636611", + "has_branch_placeholder": true } }, "directories": { "d001": { "path": ".ai_mail.local", - "name": ".ai_mail.local", - "has_branch_placeholder": false + "name": ".ai_mail.local" }, "d002": { "path": ".ai_mail.local/sent", - "name": "sent", - "has_branch_placeholder": false + "name": "sent" }, "d003": { "path": ".aipass", - "name": ".aipass", - "has_branch_placeholder": false + "name": ".aipass" }, "d004": { "path": ".archive", - "name": ".archive", - "has_branch_placeholder": false + "name": ".archive" }, "d005": { "path": ".claude", - "name": ".claude", - "has_branch_placeholder": false + "name": ".claude" }, "d006": { "path": ".seedgo", - "name": ".seedgo", - "has_branch_placeholder": false - }, - "d023": { - "path": ".spawn", - "name": ".spawn", - "has_branch_placeholder": false + "name": ".seedgo" }, "d007": { "path": ".trinity", - "name": ".trinity", - "has_branch_placeholder": false + "name": ".trinity" }, "d008": { "path": "apps", - "name": "apps", - "has_branch_placeholder": false + "name": "apps" }, "d009": { "path": "apps/handlers", - "name": "handlers", - "has_branch_placeholder": false + "name": "handlers" }, "d010": { "path": "apps/modules", - "name": "modules", - "has_branch_placeholder": false + "name": "modules" }, "d011": { "path": "apps/plugins", - "name": "plugins", - "has_branch_placeholder": false + "name": "plugins" }, "d012": { "path": "artifacts", - "name": "artifacts", - "has_branch_placeholder": false + "name": "artifacts" }, "d013": { "path": "docs", - "name": "docs", - "has_branch_placeholder": false - }, - "d019": { - "path": "docs.local", - "name": "docs.local", - "has_branch_placeholder": false - }, - "d020": { - "path": "docs.local/sub_agent_drops", - "name": "sub_agent_drops", - "has_branch_placeholder": false + "name": "docs" }, "d014": { - "path": "dropbox", - "name": "dropbox", - "has_branch_placeholder": false - }, - "d021": { - "path": "logs", - "name": "logs", - "has_branch_placeholder": false + "path": "docs.local", + "name": "docs.local" }, "d015": { - "path": "templates", - "name": "templates", - "has_branch_placeholder": false + "path": "docs.local/sub_agent_drops", + "name": "sub_agent_drops" }, "d016": { - "path": "tests", - "name": "tests", - "has_branch_placeholder": false + "path": "dropbox", + "name": "dropbox" }, "d017": { - "path": "tools", - "name": "tools", - "has_branch_placeholder": false + "path": "logs", + "name": "logs" }, "d018": { + "path": "templates", + "name": "templates" + }, + "d019": { + "path": "tests", + "name": "tests" + }, + "d020": { + "path": "tools", + "name": "tools" + }, + "d021": { "path": "{{BRANCH}}_json", - "name": "{{BRANCH}}_json", - "has_branch_placeholder": true + "name": "{{BRANCH}}_json" }, "d022": { "path": "{{BRANCH}}_json/custom_config", - "name": "custom_config", - "has_branch_placeholder": false + "name": "custom_config" } } } diff --git a/src/aipass/spawn/tests/test_citizen_classes.py b/src/aipass/spawn/tests/test_citizen_classes.py index d5896972..7941e138 100644 --- a/src/aipass/spawn/tests/test_citizen_classes.py +++ b/src/aipass/spawn/tests/test_citizen_classes.py @@ -274,3 +274,106 @@ class TestTemplateStructure: """Old agent.template directory should not exist.""" spawn_root = Path(__file__).parents[1] assert not (spawn_root / "templates" / "agent.template").exists() + + def test_builder_template_has_claude_md(self): + """Builder template includes CLAUDE.md with startup protocol.""" + from aipass.spawn.apps.handlers.class_registry import get_template_dir + builder = get_template_dir("builder") + claude_md = builder / "CLAUDE.md" + assert claude_md.exists(), "Builder template must include CLAUDE.md" + content = claude_md.read_text() + assert "{{BRANCHNAME}}" in content + assert "Startup" in content + assert ".trinity/passport.json" in content + + def test_builder_template_has_local_prompt(self): + """Builder template includes non-empty local prompt.""" + from aipass.spawn.apps.handlers.class_registry import get_template_dir + builder = get_template_dir("builder") + prompt = builder / ".aipass" / "aipass_local_prompt.md" + assert prompt.exists() + content = prompt.read_text() + assert len(content) > 100, "Local prompt should have substantial content" + assert "{{BRANCHNAME}}" in content + + +# ============================================================================= +# AGENT SCAFFOLD CONTENT TESTS +# ============================================================================= + +class TestAgentScaffoldContent: + """Tests verifying created agents have useful content.""" + + def test_created_agent_has_claude_md(self, tmp_path): + """Created agent should have CLAUDE.md with branch name substituted.""" + from aipass.spawn.apps.modules.core import _spawn_agent + target = tmp_path / "content_test" + _spawn_agent(str(target), role="Tester", purpose="Testing scaffold") + + claude_md = target / "CLAUDE.md" + assert claude_md.exists() + content = claude_md.read_text() + assert "CONTENT_TEST" in content + assert "{{BRANCHNAME}}" not in content + assert ".trinity/passport.json" in content + + def test_created_agent_local_prompt_has_content(self, tmp_path): + """Created agent's local prompt should reference branch identity.""" + from aipass.spawn.apps.modules.core import _spawn_agent + target = tmp_path / "prompt_agent" + _spawn_agent(str(target), purpose="Testing prompt") + + prompt = target / ".aipass" / "aipass_local_prompt.md" + assert prompt.exists() + content = prompt.read_text() + assert "PROMPT_AGENT" in content + assert len(content) > 100 + + def test_created_agent_claude_md_has_role(self, tmp_path): + """CLAUDE.md should include the agent's role if provided.""" + from aipass.spawn.apps.modules.core import _spawn_agent + target = tmp_path / "role_test" + _spawn_agent(str(target), role="Data Analyst", purpose="Reports") + + content = (target / "CLAUDE.md").read_text() + assert "Data Analyst" in content + + +# ============================================================================= +# MULTI-AGENT COEXISTENCE TESTS +# ============================================================================= + +class TestMultiAgentCoexistence: + """Tests verifying multiple agents can coexist in the same registry.""" + + def test_two_agents_same_registry(self, tmp_path): + """Two agents created with the same registry both register.""" + from aipass.spawn.apps.modules.core import _spawn_agent + reg = tmp_path / "TEST_REGISTRY.json" + reg.write_text('{"metadata":{"version":"1.0.0","total_branches":0},"branches":[]}') + + r1 = _spawn_agent(str(tmp_path / "agent_a"), registry_path=str(reg)) + r2 = _spawn_agent(str(tmp_path / "agent_b"), registry_path=str(reg)) + + assert r1["success"] is True + assert r2["success"] is True + + data = json.loads(reg.read_text()) + names = [b["name"] for b in data["branches"]] + assert "AGENT_A" in names + assert "AGENT_B" in names + assert data["metadata"]["total_branches"] == 2 + + def test_three_agents_distinct_identities(self, tmp_path): + """Three agents in same registry have distinct passports.""" + from aipass.spawn.apps.modules.core import _spawn_agent + reg = tmp_path / "TEST_REGISTRY.json" + reg.write_text('{"metadata":{"version":"1.0.0","total_branches":0},"branches":[]}') + + for name in ["alpha", "beta", "gamma"]: + _spawn_agent(str(tmp_path / name), registry_path=str(reg), purpose=f"{name} purpose") + + for name in ["alpha", "beta", "gamma"]: + passport = json.loads((tmp_path / name / ".trinity" / "passport.json").read_text()) + assert passport["branch_info"]["branch_name"] == name.upper() + assert passport["identity"]["citizen_class"] == "builder" diff --git a/src/aipass/spawn/tests/test_cli_routing.py b/src/aipass/spawn/tests/test_cli_routing.py index fee0548b..6f196d6b 100644 --- a/src/aipass/spawn/tests/test_cli_routing.py +++ b/src/aipass/spawn/tests/test_cli_routing.py @@ -138,6 +138,54 @@ class TestCreateDryRun: assert result == 1 +class TestTemplateFlag: + """Tests for --template flag as class selector.""" + + def test_template_flag_selects_class(self, tmp_path): + """--template birthright creates birthright-class agent.""" + from aipass.spawn.apps.spawn import handle_create + target = str(tmp_path / "tmpl_test") + with patch("aipass.spawn.apps.spawn.console"), \ + patch("aipass.spawn.apps.spawn.header"): + result = handle_create([target, "--template", "birthright"]) + assert result == 0 + # Birthright class: has .trinity but no apps/ + assert (tmp_path / "tmpl_test" / ".trinity").exists() + assert not (tmp_path / "tmpl_test" / "apps").exists() + + def test_template_flag_overrides_positional_class(self, tmp_path): + """--template overrides positional class arg.""" + from aipass.spawn.apps.spawn import handle_create + target = str(tmp_path / "override_test") + # Positional says builder, flag says birthright + with patch("aipass.spawn.apps.spawn.console"), \ + patch("aipass.spawn.apps.spawn.header"): + result = handle_create(["builder", target, "--template", "birthright"]) + assert result == 0 + # --template wins: birthright has no apps/ + assert not (tmp_path / "override_test" / "apps").exists() + + def test_template_flag_unknown_treated_as_path(self, tmp_path): + """--template with unknown value is treated as path (backward compat).""" + from aipass.spawn.apps.spawn import handle_create + target = str(tmp_path / "path_test") + with patch("aipass.spawn.apps.spawn.console"), \ + patch("aipass.spawn.apps.spawn.error") as mock_error: + result = handle_create([target, "--template", "/nonexistent/path"]) + assert result == 1 + mock_error.assert_called_once() + + def test_template_flag_dry_run(self, tmp_path): + """--template works with --dry-run.""" + from aipass.spawn.apps.spawn import handle_create + target = str(tmp_path / "drytest") + with patch("aipass.spawn.apps.spawn.console"), \ + patch("aipass.spawn.apps.spawn.header"): + result = handle_create([target, "--template", "birthright", "--dry-run"]) + assert result == 0 + assert not (tmp_path / "drytest").exists() + + class TestPassportHelp: """Tests for passport --help interception."""