diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d9fee1c1..f6d62dee 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,24 +1,10 @@ # Pre-commit hooks for AIPass # Install: pip install pre-commit && pre-commit install -<<<<<<< HEAD -# Manual run: pre-commit run --all-files - -repos: - # Gitleaks — secret detection -======= # Run manually: pre-commit run --all-files repos: ->>>>>>> 190d7a4 (feat(system): SECURITY: gitleaks config + pre-commit for API key leak prevention) + # Gitleaks — secret detection - repo: https://github.com/gitleaks/gitleaks rev: v8.21.2 hooks: - id: gitleaks -<<<<<<< HEAD -======= - name: gitleaks (secret detection) - description: Detect hardcoded secrets using gitleaks - entry: gitleaks protect --verbose --redact --staged - language: golang - pass_filenames: false ->>>>>>> 190d7a4 (feat(system): SECURITY: gitleaks config + pre-commit for API key leak prevention) diff --git a/src/aipass/ai_mail/apps/ai_mail.py b/src/aipass/ai_mail/apps/ai_mail.py index 8c489a22..8125ce37 100644 --- a/src/aipass/ai_mail/apps/ai_mail.py +++ b/src/aipass/ai_mail/apps/ai_mail.py @@ -206,39 +206,43 @@ def route_command(command: str, args: List[str], modules: List[Any]) -> bool: def main(): """Main entry point - routes commands to modules""" + try: + # Parse arguments + args = sys.argv[1:] - # Parse arguments - args = sys.argv[1:] + # Show introspection when run without arguments + if len(args) == 0: + print_introspection() + return 0 - # Show introspection when run without arguments - if len(args) == 0: - print_introspection() - return 0 + # Show version + if args[0] in ['--version', '-V']: + console.print("AI_MAIL v1.0.0") + return 0 - # Show version - if args[0] in ['--version', '-V']: - console.print("AI_MAIL v1.0.0") - return 0 + # Show help for explicit help flags + if args[0] in ['--help', '-h', 'help']: + print_help() + return 0 - # Show help for explicit help flags - if args[0] in ['--help', '-h', 'help']: - print_help() - return 0 + # Command provided - try to route to modules + modules = discover_modules() + command = args[0] + remaining_args = args[1:] if len(args) > 1 else [] - # Command provided - try to route to modules - modules = discover_modules() - command = args[0] - remaining_args = args[1:] if len(args) > 1 else [] + if not modules: + error("No modules found") + return 1 - if not modules: - error("No modules found") - return 1 + # Route command + if route_command(command, remaining_args, modules): + return 0 + else: + error(f"Unknown command: {command}") + return 1 - # Route command - if route_command(command, remaining_args, modules): - return 0 - else: - error(f"Unknown command: {command}") + except Exception as exc: + logger.error('[ai_mail] Unhandled error in main: %s', exc) return 1 if __name__ == "__main__": diff --git a/src/aipass/api/README.md b/src/aipass/api/README.md index ef4c5d4a..dc54a2bc 100644 --- a/src/aipass/api/README.md +++ b/src/aipass/api/README.md @@ -123,8 +123,6 @@ api/ ### Provides To - All branches -- authenticated external API clients -- `@backup` -- Google Drive service (migration from self-contained auth) -- `@skills` -- future external API integrations (Telegram, Google services) - System-wide API key management and credential validation ### Credentials diff --git a/src/aipass/api/apps/handlers/openrouter/caller.py b/src/aipass/api/apps/handlers/openrouter/caller.py index 8de96cb5..b93ed5c1 100644 --- a/src/aipass/api/apps/handlers/openrouter/caller.py +++ b/src/aipass/api/apps/handlers/openrouter/caller.py @@ -42,7 +42,6 @@ MODULE_VERSION = "1.0.0" CALLER_PATTERNS = { "flow": "flow_json", "prax": "prax_json", - "skills": "{category}_json", } # ============================================= @@ -70,11 +69,6 @@ def get_caller_info() -> Optional[Dict[str, Any]]: result = _detect_prax_caller(frame_path) json_handler.log_operation("caller_detected", {"caller": result.get("caller_name"), "category": "prax"}) return result - elif any("skills" in part for part in frame_path.parts): - result = _detect_skills_caller(frame_path) - json_handler.log_operation("caller_detected", {"caller": result.get("caller_name"), "category": "skills"}) - return result - logger.info(f"[{MODULE_NAME}] Could not detect caller from stack trace") return None @@ -105,8 +99,6 @@ def detect_caller_category(caller_path: Path) -> str: return "flow" elif "prax" in path_parts: return "prax" - elif any("skills" in part for part in path_parts): - return "skills" else: return "unknown" @@ -165,38 +157,6 @@ def _detect_prax_caller(frame_path: Path) -> Dict[str, Any]: return _create_fallback_info(frame_path) -def _detect_skills_caller(frame_path: Path) -> Dict[str, Any]: - """ - Detect skills module caller from stack frame path. - Skills have category subdirectories (e.g., /skills/skills_api/skill.py) - """ - try: - for i, part in enumerate(frame_path.parts): - if "skills" in part: - skills_path = Path(*frame_path.parts[:i + 2]) - category = frame_path.parts[i + 1] if i + 1 < len(frame_path.parts) else "skills_api" - json_folder_path = skills_path / f"{category}_json" - caller_name = frame_path.stem - - logger.info(f"[{MODULE_NAME}] Detected skills caller: {caller_name} (category: {category})") - - return { - "caller_name": caller_name, - "caller_path": frame_path, - "json_folder": json_folder_path, - "category": "skills", - "skills_category": category, - "detection_method": "stack" - } - - logger.info(f"[{MODULE_NAME}] Could not find skills directory in path: {frame_path}") - return _create_fallback_info(frame_path) - - except Exception as e: - logger.error(f"Failed to detect skills caller: {e}") - return _create_fallback_info(frame_path) - - def _create_fallback_info(frame_path: Path) -> Dict[str, Any]: """Create fallback caller info when detection fails.""" caller_name = frame_path.stem diff --git a/src/aipass/api/apps/handlers/openrouter/client.py b/src/aipass/api/apps/handlers/openrouter/client.py index e93314ed..639abcbf 100644 --- a/src/aipass/api/apps/handlers/openrouter/client.py +++ b/src/aipass/api/apps/handlers/openrouter/client.py @@ -256,13 +256,17 @@ def extract_response(response: Any) -> Optional[Dict[str, Any]]: return None # Extract metadata + finish_reason = response.choices[0].finish_reason if hasattr(response.choices[0], 'finish_reason') else None result = { "content": content, "id": response.id if hasattr(response, 'id') else None, "model": response.model if hasattr(response, 'model') else None, - "finish_reason": response.choices[0].finish_reason if hasattr(response.choices[0], 'finish_reason') else None + "finish_reason": finish_reason } + if finish_reason == "content_filter": + logger.warning("Response was truncated or blocked by content filter") + logger.info(f"Extracted response - length: {len(content)} chars, id: {result['id']}") return result diff --git a/src/aipass/api/apps/handlers/openrouter/provision.py b/src/aipass/api/apps/handlers/openrouter/provision.py index 9e17fe8a..e3cfa99c 100644 --- a/src/aipass/api/apps/handlers/openrouter/provision.py +++ b/src/aipass/api/apps/handlers/openrouter/provision.py @@ -172,9 +172,9 @@ def create_caller_config(caller: str, json_folder: Path) -> Dict[str, Any]: Create new caller configuration with defaults Creates complete 3-file JSON structure: - - openrouter_skill_config.json (API settings) - - openrouter_skill_data.json (usage tracking) - - openrouter_skill_log.json (operation log) + - openrouter_config.json (API settings) + - openrouter_data.json (usage tracking) + - openrouter_log.json (operation log) Args: caller: Name of calling module @@ -189,7 +189,7 @@ def create_caller_config(caller: str, json_folder: Path) -> Dict[str, Any]: return {} # Create config file - config_file = json_folder / "openrouter_skill_config.json" + config_file = json_folder / "openrouter_config.json" config = get_default_caller_config() if not write_json(config_file, config): @@ -199,7 +199,7 @@ def create_caller_config(caller: str, json_folder: Path) -> Dict[str, Any]: logger.info(f"Created config: {config_file.name}") # Create data file - data_file = json_folder / "openrouter_skill_data.json" + data_file = json_folder / "openrouter_data.json" data = get_default_caller_data() if write_json(data_file, data): @@ -207,7 +207,7 @@ def create_caller_config(caller: str, json_folder: Path) -> Dict[str, Any]: logger.info(f"Created data: {data_file.name}") # Create log file - log_file = json_folder / "openrouter_skill_log.json" + log_file = json_folder / "openrouter_log.json" log_data = get_default_caller_log() if write_json(log_file, log_data): @@ -258,7 +258,7 @@ def ensure_caller_config(caller: str | None = None) -> Dict[str, Any]: return {} # Check if config already exists - config_file = json_folder / "openrouter_skill_config.json" + config_file = json_folder / "openrouter_config.json" if config_file.exists(): config = read_json(config_file) diff --git a/src/aipass/api/apps/handlers/usage/cleanup.py b/src/aipass/api/apps/handlers/usage/cleanup.py index 07eacc4a..9efbbb5c 100644 --- a/src/aipass/api/apps/handlers/usage/cleanup.py +++ b/src/aipass/api/apps/handlers/usage/cleanup.py @@ -101,7 +101,7 @@ def cleanup_old_data(data_file_path: Path, retention_days: int = DEFAULT_RETENTI except Exception as e: logger.error(f"Cleanup failed: {e}") - raise + return 0 def _identify_old_generations(generation_tracking: Dict, cutoff_date: datetime) -> List[str]: diff --git a/src/aipass/api/apps/handlers/usage/tracking.py b/src/aipass/api/apps/handlers/usage/tracking.py index 66616bf4..ffb16d43 100644 --- a/src/aipass/api/apps/handlers/usage/tracking.py +++ b/src/aipass/api/apps/handlers/usage/tracking.py @@ -49,6 +49,7 @@ GENERATION_ENDPOINT = f"{OPENROUTER_BASE_URL}/generation" # Default configuration values DEFAULT_GENERATION_CHECK_DELAY = 2 # seconds to wait before querying metrics DEFAULT_REQUEST_TIMEOUT = 30 # seconds for HTTP request timeout +MAX_GENERATION_TRACKING = 500 # Maximum entries in generation_tracking before trimming # ============================================= @@ -161,15 +162,15 @@ def get_generation_metrics(generation_id: str, api_key: str) -> Optional[Dict[st logger.warning(f"[{MODULE_NAME}] Invalid response structure from OpenRouter for generation {generation_id}") return None - # Extract metrics from response + # Extract metrics from response (use `or 0` to handle explicit None values) metrics = data["data"] result = { - "total_cost": float(metrics.get("total_cost", 0)), - "tokens_prompt": int(metrics.get("tokens_prompt", 0)), - "tokens_completion": int(metrics.get("tokens_completion", 0)), - "generation_time": int(metrics.get("generation_time", 0)), - "latency": int(metrics.get("latency", 0)), - "provider_name": metrics.get("provider_name", "unknown") + "total_cost": float(metrics.get("total_cost") or 0), + "tokens_prompt": int(metrics.get("tokens_prompt") or 0), + "tokens_completion": int(metrics.get("tokens_completion") or 0), + "generation_time": int(metrics.get("generation_time") or 0), + "latency": int(metrics.get("latency") or 0), + "provider_name": metrics.get("provider_name") or "unknown" } # Retrieved metrics for generation_id @@ -281,6 +282,20 @@ def store_usage_data(caller: str, model: str, generation_id: str, metrics: Dict[ current_data["daily_totals"][today]["cost"] += metrics["total_cost"] current_data["daily_totals"][today]["tokens"] += total_tokens + # Update monthly totals + month = today[:7] # YYYY-MM + if "monthly_totals" not in current_data: + current_data["monthly_totals"] = {} + if month not in current_data["monthly_totals"]: + current_data["monthly_totals"][month] = { + "requests": 0, + "cost": 0.0, + "tokens": 0 + } + current_data["monthly_totals"][month]["requests"] += 1 + current_data["monthly_totals"][month]["cost"] += metrics["total_cost"] + current_data["monthly_totals"][month]["tokens"] += total_tokens + # Store generation details with newest-first ordering new_entry = { "timestamp": datetime.now().isoformat(), @@ -293,6 +308,12 @@ def store_usage_data(caller: str, model: str, generation_id: str, metrics: Dict[ current_tracking = current_data["generation_tracking"] current_data["generation_tracking"] = {generation_id: new_entry, **current_tracking} + # Trim oldest entries if over the size cap + if len(current_data["generation_tracking"]) > MAX_GENERATION_TRACKING: + keys = list(current_data["generation_tracking"].keys()) + for old_key in keys[MAX_GENERATION_TRACKING:]: + del current_data["generation_tracking"][old_key] + # Save updated data with proper wrapper structure data_wrapper = { "module_name": "api_usage", diff --git a/src/aipass/api/tests/test_caller.py b/src/aipass/api/tests/test_caller.py index 18aa577e..cdad7bdc 100644 --- a/src/aipass/api/tests/test_caller.py +++ b/src/aipass/api/tests/test_caller.py @@ -12,9 +12,7 @@ Tests for openrouter.caller — caller detection handler. Tests: - detect_caller_category for flow paths - detect_caller_category for prax paths -- detect_caller_category for skills paths -- detect_caller_category for unknown paths -- detect_caller_category with 'skills' as substring in a part +- detect_caller_category for unknown paths (including former skills paths) """ from unittest.mock import patch, MagicMock @@ -43,18 +41,13 @@ class TestDetectCallerCategory: path = Path("/home/user/projects/aipass/src/aipass/prax/monitor.py") assert detect_caller_category(path) == "prax" - def test_skills_exact_part_returns_skills(self): - """Path with exact 'skills' directory should return 'skills'.""" + def test_skills_path_returns_unknown(self): + """Skills branch was removed — skills paths now return 'unknown'.""" path = Path("/home/user/projects/aipass/src/aipass/skills/skills_api/tool.py") - assert detect_caller_category(path) == "skills" - - def test_skills_substring_returns_skills(self): - """Path with 'skills' as substring in a part (e.g., 'skills_api') should return 'skills'.""" - path = Path("/home/user/projects/aipass/src/aipass/modules/skills_custom/handler.py") - assert detect_caller_category(path) == "skills" + assert detect_caller_category(path) == "unknown" def test_unknown_path_returns_unknown(self): - """Path without flow, prax, or skills should return 'unknown'.""" + """Path without flow or prax should return 'unknown'.""" path = Path("/home/user/projects/aipass/src/aipass/api/apps/handler.py") assert detect_caller_category(path) == "unknown" @@ -63,9 +56,9 @@ class TestDetectCallerCategory: path = Path("/home/user/flow/prax/script.py") assert detect_caller_category(path) == "flow" - def test_prax_takes_priority_over_skills(self): - """If 'prax' appears before a skills part, should return 'prax'.""" - path = Path("/home/user/prax/skills_module/script.py") + def test_prax_in_mixed_path(self): + """'prax' in path should return 'prax' regardless of other parts.""" + path = Path("/home/user/prax/other_module/script.py") assert detect_caller_category(path) == "prax" def test_root_path_returns_unknown(self): diff --git a/src/aipass/api/tests/test_provision.py b/src/aipass/api/tests/test_provision.py index b4cb7874..a1ed5ea6 100644 --- a/src/aipass/api/tests/test_provision.py +++ b/src/aipass/api/tests/test_provision.py @@ -46,9 +46,9 @@ def test_create_caller_config_creates_three_files(tmp_path: Path): result = create_caller_config("test_caller", json_folder) assert result != {} - assert (json_folder / "openrouter_skill_config.json").exists() - assert (json_folder / "openrouter_skill_data.json").exists() - assert (json_folder / "openrouter_skill_log.json").exists() + assert (json_folder / "openrouter_config.json").exists() + assert (json_folder / "openrouter_data.json").exists() + assert (json_folder / "openrouter_log.json").exists() def test_create_caller_config_defaults(tmp_path: Path): @@ -57,7 +57,7 @@ def test_create_caller_config_defaults(tmp_path: Path): create_caller_config("test_caller", json_folder) - config = read_json(json_folder / "openrouter_skill_config.json") + config = read_json(json_folder / "openrouter_config.json") assert config is not None assert config["config"]["ai_temperature"] == 0.7 assert config["config"]["ai_max_tokens"] == 4000 @@ -72,7 +72,7 @@ def test_create_caller_config_data_defaults(tmp_path: Path): create_caller_config("test_caller", json_folder) - data = read_json(json_folder / "openrouter_skill_data.json") + data = read_json(json_folder / "openrouter_data.json") assert data is not None assert data["data"]["total_requests"] == 0 assert data["data"]["successful_requests"] == 0 @@ -87,7 +87,7 @@ def test_create_caller_config_log_defaults(tmp_path: Path): create_caller_config("test_caller", json_folder) - log = read_json(json_folder / "openrouter_skill_log.json") + log = read_json(json_folder / "openrouter_log.json") assert log is not None assert log["logs"] == [] assert log["module_name"] == "openrouter" @@ -143,9 +143,9 @@ def test_ensure_caller_config_provisions_new(mock_detect, tmp_path: Path): result = ensure_caller_config("test_caller") assert result != {} - assert (json_folder / "openrouter_skill_config.json").exists() - assert (json_folder / "openrouter_skill_data.json").exists() - assert (json_folder / "openrouter_skill_log.json").exists() + assert (json_folder / "openrouter_config.json").exists() + assert (json_folder / "openrouter_data.json").exists() + assert (json_folder / "openrouter_log.json").exists() @patch("aipass.api.apps.handlers.openrouter.provision.detect_caller_from_stack") @@ -159,7 +159,7 @@ def test_ensure_caller_config_returns_existing(mock_detect, tmp_path: Path): assert first_result != {} # Read the created config and modify it to detect overwrites - config_path = json_folder / "openrouter_skill_config.json" + config_path = json_folder / "openrouter_config.json" config = read_json(config_path) assert config is not None config["config"]["ai_model"] = "test/modified-model" diff --git a/src/aipass/cli/README.md b/src/aipass/cli/README.md index 15b099cf..cc7c185d 100644 --- a/src/aipass/cli/README.md +++ b/src/aipass/cli/README.md @@ -5,8 +5,8 @@ **Purpose:** Display and output formatting service for AIPass modules. Provides consistent terminal output — headers, success/error/warning messages, section breaks, and operation templates — so every module looks the same without duplicating Rich formatting code. **Module:** `aipass.cli` **Seedgo:** 100% -**Tests:** 138 passing (6 files, 5/5 modules covered) -**Last Updated:** 2026-04-07 +**Tests:** 142 passing (6 files, 5/5 modules covered) +**Last Updated:** 2026-04-10 ## Usage @@ -66,7 +66,7 @@ cli/ ├── cli_json/ # Auto-created JSON output (three-file pattern) ├── dropbox/ # Inbound file drop ├── logs/ # Branch-level logs -├── tests/ # 138 tests across 6 files +├── tests/ # 142 tests across 6 files │ ├── test_bootstrap.py # bootstrap.py handler tests │ ├── test_json_handler.py # json_handler tests │ ├── test_display.py # display module tests @@ -90,6 +90,7 @@ drone @cli aipass # Show aipass subcommands drone @cli aipass init # Bootstrap AIPass project in current dir drone @cli aipass init /path # Bootstrap in target directory drone @cli aipass init /path MyProject # Bootstrap with custom name +drone @cli aipass init agent # Create agent in project (routes to spawn) drone @cli aipass init --help # Detailed init usage drone @cli display # Display module introspection drone @cli display demo # Run display function showcase @@ -119,7 +120,7 @@ aipass --help # Via console_scripts entry point --- -*Last Updated: 2026-04-07* +*Last Updated: 2026-04-10* --- [← Back to AIPass](../../../README.md) diff --git a/src/aipass/cli/apps/modules/display.py b/src/aipass/cli/apps/modules/display.py index 9549a39a..b46fde8e 100755 --- a/src/aipass/cli/apps/modules/display.py +++ b/src/aipass/cli/apps/modules/display.py @@ -14,6 +14,7 @@ Provides display functions for all branches: - success() - Green checkmark + message - error() - Red X + error message - warning() - Yellow warning + message +- fatal() - Error + sys.exit(1) - section() - Visual section breaks Uses Rich library for beautiful terminal output. @@ -97,7 +98,7 @@ def print_help(): CONSOLE.print("Display is the [bold]CLI's universal output service[/bold] that provides:") # RICH FORMATTING TIP: Use [green]✓[/green] for checkmarks in lists CONSOLE.print(" [green]✓[/green] Consistent Rich-formatted output across all branches") - CONSOLE.print(" [green]✓[/green] Five core display functions ([green]header, success, error, warning, section[/green])") + CONSOLE.print(" [green]✓[/green] Six core display functions ([green]header, success, error, warning, fatal, section[/green])") CONSOLE.print(" [green]✓[/green] Beautiful terminal output with colors, panels, and formatting") CONSOLE.print(" [green]✓[/green] Integration with CLI error handler for advanced error display") CONSOLE.print() @@ -106,7 +107,7 @@ def print_help(): # RICH FORMATTING TIP: Tables are powerful for structured data # Create with Table(), add columns, add rows, then print # ========================================================================= - CONSOLE.print("[bold cyan]PUBLIC API FUNCTIONS (5 total):[/bold cyan]") + CONSOLE.print("[bold cyan]PUBLIC API FUNCTIONS (6 total):[/bold cyan]") CONSOLE.print() # RICH FORMATTING TIP: Table styling - show_header, header_style, border_style @@ -119,6 +120,7 @@ def print_help(): table.add_row("success()", "message, **kwargs", "Success messages with green checkmark + optional details") table.add_row("error()", "message, suggestion=None", "Error messages with red X + optional suggestion") table.add_row("warning()", "message, details=None", "Warning messages with yellow symbol + optional details") + table.add_row("fatal()", "message, suggestion=None", "Error message + sys.exit(1) for unrecoverable failures") table.add_row("section()", "title", "Visual section separators with title and line") # RICH FORMATTING TIP: Print the table after adding all rows diff --git a/src/aipass/cli/apps/modules/init_project.py b/src/aipass/cli/apps/modules/init_project.py index cb2b67ec..e9cd1562 100644 --- a/src/aipass/cli/apps/modules/init_project.py +++ b/src/aipass/cli/apps/modules/init_project.py @@ -201,7 +201,12 @@ def _handle_init(args: List[str]) -> bool: # 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) + if args: + target = Path(args[0]) + if not target.is_absolute(): + target = Path(caller_cwd) / target + else: + target = Path(caller_cwd) project_name = args[1] if len(args) > 1 else None try: @@ -210,10 +215,6 @@ def _handle_init(args: List[str]) -> bool: logger.warning("Init validation error: %s", exc) error(str(exc), suggestion="Pass a project name explicitly") sys.exit(1) - except FileExistsError as exc: - logger.warning("Init target already exists: %s", exc) - error(str(exc)) - sys.exit(1) except OSError as exc: logger.error("Init filesystem error: %s", exc) error(f"Filesystem error: {exc}") @@ -283,11 +284,12 @@ def _handle_init_agent(args: List[str]) -> bool: agent_name = args[0] agent_path = f"src/{agent_name}" + extra_flags = args[1:] logger.info("Routing 'init agent %s' to drone @spawn create %s", agent_name, agent_path) try: result = subprocess.run( - ["drone", "@spawn", "create", agent_path], + ["drone", "@spawn", "create", agent_path] + extra_flags, check=False, ) if result.returncode != 0: diff --git a/src/aipass/cli/apps/modules/templates.py b/src/aipass/cli/apps/modules/templates.py index e8c76b3e..47bd005c 100755 --- a/src/aipass/cli/apps/modules/templates.py +++ b/src/aipass/cli/apps/modules/templates.py @@ -60,7 +60,7 @@ def print_help(): CONSOLE.print(" Standard operation header with Rich styling") CONSOLE.print(" Example: operation_start('Creating branch', target='/path', type='module')") CONSOLE.print() - CONSOLE.print(" [yellow]operation_complete(success=None, results=None, **summary)[/yellow]") + CONSOLE.print(" [yellow]operation_complete(**summary)[/yellow]") CONSOLE.print(" Standard completion summary with Rich styling") CONSOLE.print(" Example: operation_complete(created=5, skipped=2, time='3.2s')") CONSOLE.print() @@ -174,16 +174,15 @@ def operation_start(operation: str, **details) -> None: CONSOLE.print() -def operation_complete(success: bool | None = None, **summary) -> None: +def operation_complete(**summary) -> None: """ Display standard operation completion template with Rich styling Args: - success: True if successful, False if errors **summary: Summary statistics Example: - operation_complete(True, created=5, skipped=2, time='3.2s') + operation_complete(created=5, skipped=2, time='3.2s') """ CONSOLE.print() CONSOLE.print("─" * 50) diff --git a/src/aipass/devpulse/README.md b/src/aipass/devpulse/README.md index c3bae9a1..4ce8072c 100644 --- a/src/aipass/devpulse/README.md +++ b/src/aipass/devpulse/README.md @@ -5,7 +5,7 @@ **Purpose:** Orchestration hub for the AIPass ecosystem **Module:** `aipass.devpulse` **Status:** Active -**Last Updated:** 2026-03-22 +**Last Updated:** 2026-04-10 --- @@ -56,21 +56,25 @@ devpulse/ ├── .aipass/ # AI context │ └── aipass_local_prompt.md ├── .spawn/ # Spawn metadata -├── tools/ # Diagnostic scanner suite (20 tools) +├── apps/ # Entry point scaffold (minimal — devpulse.py + stubs) +├── tools/ # Diagnostic scanner suite (26 tools) +├── branch_audits _only/ # Living audit plans for all 11 branches + patrick +├── dropbox/ # Incoming files from other branches/users ├── docs/ # Tracked documentation ├── docs.local/ # Working files (gitignored) -├── tests/ +├── DPLAN-*.md # Active design plans (8-10 at any time) +├── CLOSED_PLANS.local.json # Archive of completed plans ├── STATUS.local.md # Current work, issues, todos └── README.md ``` -DevPulse has no `apps/` directory — it's a **manager** branch, not a builder. It coordinates via dispatch and sub-agents rather than implementing code. +DevPulse has a minimal `apps/` scaffold but is primarily a **manager** branch, not a builder. It coordinates via dispatch and sub-agents rather than implementing code. --- ## Diagnostic Tools -DevPulse maintains a suite of 20 standalone diagnostic scanners in `tools/`. Each follows the `{concern}_scanner_v1.py` naming convention and supports `@branch`, `--all`, and `--summary` flags. +DevPulse maintains a suite of 26 standalone diagnostic scanners in `tools/`. Each follows the `{concern}_scanner_v1.py` naming convention and supports `@branch`, `--all`, and `--summary` flags. ### Code Quality | Tool | What it checks | @@ -104,16 +108,53 @@ DevPulse maintains a suite of 20 standalone diagnostic scanners in `tools/`. Eac | `magic_number_scanner_v1.py` | Hardcoded numbers (cross-file consistency) | | `stale_scanner_v1.py` | Outdated terminology (17 tracked keywords) | +### Infrastructure +| Tool | What it checks | +|------|---------------| +| `log_scanner_v1.py` | Prax log files for errors/warnings | +| `key_exposure_scanner_v1.py` | API key exposure patterns in code | +| `meta_header_scanner_v1.py` | Module metadata headers compliance | +| `scanner_v1.py` | Base scanner framework | + ### Utilities | Tool | What it does | |------|-------------| | `dev_central_to_devpulse.py` | Rename stale terms across files | | `verify_branch.py` | Verify branch structure compliance | +| `git_lock_tool.py` | Manage git lock state for PRs | **Tool classification:** Hard checks (pass/fail) vs Advisory (flag for investigation). See DPLAN-0030 for full details. --- +## Branch Audits + +DevPulse maintains living audit plans for every branch in `branch_audits _only/`. These are comprehensive health assessments created during adversarial audit sessions (S83 Wave 1-3) and continuously updated. + +Each audit tracks: +- **CRITICAL** — Must-fix issues (security, data loss, broken core features) +- **BUG** — Functional defects +- **STALE/DEAD** — Outdated references, dead code +- **QUALITY** — Performance, thread safety, architectural debt +- **DOCS** — Documentation gaps and inaccuracies + +| Branch | Audit File | Health | +|--------|-----------|--------| +| drone | DPLAN-0053 | YELLOW (23 BUG) | +| seedgo | DPLAN-0084 | GREEN | +| prax | DPLAN-0039 | GREEN | +| cli | DPLAN-0074 | YELLOW (2 CRITICAL) | +| flow | DPLAN-0082 | YELLOW (5 CRITICAL) | +| ai_mail | DPLAN-0036 | GREEN | +| api | DPLAN-0029 | YELLOW (3 CRITICAL) | +| trigger | DPLAN-0075 | YELLOW (1 CRITICAL) | +| spawn | DPLAN-0035 | YELLOW (12 BUG) | +| memory | DPLAN-0038 | GREEN | +| devpulse | DPLAN-0037 | RED | +| patrick | DPLAN-0086 | N/A | + +--- + ## Commands ```bash @@ -160,7 +201,9 @@ drone @spawn delete @branch # Archive + deregister branch DevPulse is a **manager** branch, not a builder. It delegates code tasks to sub-agents and branch agents. Its context window is reserved for coordination, planning, and architecture — not for reading and editing files across the codebase. -The `tools/` directory is DevPulse's "tool shed" — standalone diagnostic scripts for investigating code quality across all branches. These tools surface patterns and create conversations. They're built for AI consumption: run a scanner, get instant visibility, decide what matters. +The `tools/` directory is DevPulse's "tool shed" — 26 standalone diagnostic scripts for investigating code quality across all branches. These tools surface patterns and create conversations. They're built for AI consumption: run a scanner, get instant visibility, decide what matters. + +The `branch_audits _only/` directory is DevPulse's health dashboard — living audit documents for every branch, updated during adversarial audit sessions and fix sweeps. Each audit tracks CRITICALs, BUGs, quality debt, and documentation gaps with current health ratings. --- [← Back to AIPass](../../../README.md) diff --git a/src/aipass/drone/apps/drone.py b/src/aipass/drone/apps/drone.py index 0b1b7242..752164aa 100644 --- a/src/aipass/drone/apps/drone.py +++ b/src/aipass/drone/apps/drone.py @@ -136,10 +136,12 @@ def show_introspection() -> None: # COMMAND HANDLERS # ============================================================================= -def _cwd_has_registry() -> bool: +def _cwd_has_registry(max_depth: int = 10) -> bool: """Check if CWD is within a project that has a *_REGISTRY.json.""" cwd = Path.cwd() - for parent in [cwd] + list(cwd.parents): + for i, parent in enumerate([cwd] + list(cwd.parents)): + if i >= max_depth: + break if list(parent.glob("*_REGISTRY.json")): return True return False diff --git a/src/aipass/drone/apps/handlers/generic_adapter.py b/src/aipass/drone/apps/handlers/generic_adapter.py index e1e87ba6..eeb0f00e 100644 --- a/src/aipass/drone/apps/handlers/generic_adapter.py +++ b/src/aipass/drone/apps/handlers/generic_adapter.py @@ -18,11 +18,14 @@ from __future__ import annotations import importlib import sys +import threading from io import StringIO from aipass.prax import logger from aipass.drone.apps.handlers.json import json_handler +_capture_lock = threading.Lock() + def capture_main( entry_point_module: str, @@ -54,33 +57,35 @@ def capture_main( argv_parts.append(command) argv_parts.extend(args) - original_argv = sys.argv - old_stdout = sys.stdout - old_stderr = sys.stderr captured_out = StringIO() captured_err = StringIO() - try: - sys.argv = argv_parts - sys.stdout = captured_out - sys.stderr = captured_err + with _capture_lock: + original_argv = sys.argv + old_stdout = sys.stdout + old_stderr = sys.stderr - mod = importlib.import_module(entry_point_module) - main_fn = getattr(mod, "main") - exit_code = main_fn() - except SystemExit as exc: - exit_code = exc.code if exc.code is not None else 0 - logger.info("capture_main: SystemExit(%s) from '%s'", exit_code, entry_point_module) - except Exception as exc: - captured_err.write(str(exc)) - exit_code = 1 - logger.warning( - "capture_main: exception from '%s': %s", entry_point_module, exc - ) - finally: - sys.argv = original_argv - sys.stdout = old_stdout - sys.stderr = old_stderr + try: + sys.argv = argv_parts + sys.stdout = captured_out + sys.stderr = captured_err + + mod = importlib.import_module(entry_point_module) + main_fn = getattr(mod, "main") + exit_code = main_fn() + except SystemExit as exc: + exit_code = exc.code if exc.code is not None else 0 + logger.info("capture_main: SystemExit(%s) from '%s'", exit_code, entry_point_module) + except Exception as exc: + captured_err.write(str(exc)) + exit_code = 1 + logger.warning( + "capture_main: exception from '%s': %s", entry_point_module, exc + ) + finally: + sys.argv = original_argv + sys.stdout = old_stdout + sys.stderr = old_stderr result = { "stdout": captured_out.getvalue(), diff --git a/src/aipass/drone/apps/handlers/module_registry_handler.py b/src/aipass/drone/apps/handlers/module_registry_handler.py index 11ae87c0..78af9a61 100644 --- a/src/aipass/drone/apps/handlers/module_registry_handler.py +++ b/src/aipass/drone/apps/handlers/module_registry_handler.py @@ -82,6 +82,15 @@ def _load_external_modules() -> dict[str, _ExternalModuleConfig]: _EXTERNAL_MODULES: dict[str, _ExternalModuleConfig] = _load_external_modules() +def refresh_external_modules() -> None: + """Reload external module declarations from routing_config.json. + + Call after modifying routing_config.json at runtime. + """ + global _EXTERNAL_MODULES + _EXTERNAL_MODULES = _load_external_modules() + + @dataclass class ModuleInfo: """Metadata about a registered module.""" @@ -163,6 +172,9 @@ def route_module_command( mod = importlib.import_module(adapter_path) handler = getattr(mod, "handle_command") result = handler(command, args) + # Internal modules may return bool (standard) instead of dict (adapter) + if isinstance(result, bool): + result = {"stdout": "", "stderr": "", "exit_code": 0 if result else 1} json_handler.log_operation( "route_module_command", {"module": name, "command": command} ) diff --git a/src/aipass/prax/apps/prax.py b/src/aipass/prax/apps/prax.py index 7f885288..c188015f 100755 --- a/src/aipass/prax/apps/prax.py +++ b/src/aipass/prax/apps/prax.py @@ -225,4 +225,8 @@ Examples: return 1 if __name__ == "__main__": - sys.exit(main()) + try: + sys.exit(main()) + except Exception as exc: + logger.error("Unhandled error in main: %s", exc) + sys.exit(1) diff --git a/src/aipass/seedgo/apps/seedgo.py b/src/aipass/seedgo/apps/seedgo.py index 379f855c..a5836706 100644 --- a/src/aipass/seedgo/apps/seedgo.py +++ b/src/aipass/seedgo/apps/seedgo.py @@ -226,32 +226,36 @@ def print_help() -> None: def main() -> int: """Main entry point - routes to modules.""" - modules = discover_modules() - args = sys.argv[1:] + try: + modules = discover_modules() + args = sys.argv[1:] - # No args → introspection (discovery mode) - if not args: - print_introspection() - return 0 + # No args → introspection (discovery mode) + if not args: + print_introspection() + return 0 - # Help flag → full help with usage - if args[0] in ["--help", "-h", "help"]: - print_help() - return 0 + # Help flag → full help with usage + if args[0] in ["--help", "-h", "help"]: + print_help() + return 0 - if args[0] in ["--version", "-V"]: - console.print(f"seedgo v{VERSION}") - return 0 + if args[0] in ["--version", "-V"]: + console.print(f"seedgo v{VERSION}") + return 0 - command = args[0] - remaining = args[1:] if len(args) > 1 else [] + command = args[0] + remaining = args[1:] if len(args) > 1 else [] - # Route to modules - if route_command(command, remaining, modules): - return 0 + # Route to modules + if route_command(command, remaining, modules): + return 0 - error(f"Unknown command: {command}", suggestion="Run 'seedgo --help' for usage") - return 1 + error(f"Unknown command: {command}", suggestion="Run 'seedgo --help' for usage") + return 1 + except Exception as exc: + logger.error('[seedgo] Unhandled error in main: %s', exc) + raise if __name__ == "__main__": diff --git a/src/aipass/spawn/apps/handlers/delete_ops.py b/src/aipass/spawn/apps/handlers/delete_ops.py index 192c352f..c855f445 100644 --- a/src/aipass/spawn/apps/handlers/delete_ops.py +++ b/src/aipass/spawn/apps/handlers/delete_ops.py @@ -26,9 +26,6 @@ from aipass.spawn.apps.handlers.registry import ( ) from aipass.spawn.apps.handlers.json import json_handler -# Repo root — resolved from spawn package location -_REPO_ROOT = Path(__file__).parents[5] # handlers/apps/spawn/aipass/src/AIPass - # Branches that cannot be deleted (critical infrastructure) _PROTECTED_BRANCHES = {"spawn", "devpulse", "drone"} @@ -78,6 +75,7 @@ def delete_branch( # 1. Resolve branch path from registry registry_path = find_registry() + project_root = registry_path.parent registry = load_registry(registry_path) branch_entry = None branch_dir = None @@ -87,7 +85,7 @@ def delete_branch( branch_entry = entry rel_path = entry.get("path", "") if rel_path: - branch_dir = (_REPO_ROOT / rel_path).resolve() + branch_dir = (project_root / rel_path).resolve() break if branch_entry is None: @@ -131,7 +129,7 @@ def delete_branch( # 4. Dry run — report and return timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - archive_dir = _REPO_ROOT / ".archive" / "deleted_branches" / f"{branch_name}_{timestamp}" + archive_dir = project_root / ".archive" / "deleted_branches" / f"{branch_name}_{timestamp}" if dry_run: logger.info(f"[delete] Dry run: would archive {branch_name} to {archive_dir}") @@ -151,6 +149,12 @@ def delete_branch( except Exception as exc: msg = f"Failed to create archive: {exc}" logger.error(f"[delete] {msg}") + # Clean up partial archive to avoid stale leftovers + if archive_dir.exists(): + try: + shutil.rmtree(str(archive_dir)) + except Exception as cleanup_exc: + logger.warning("[delete] Failed to clean up partial archive %s: %s", archive_dir, cleanup_exc) return { "branch": branch_name, "success": False, diff --git a/src/aipass/spawn/apps/handlers/file_ops.py b/src/aipass/spawn/apps/handlers/file_ops.py index d48d6baf..17de0e9b 100644 --- a/src/aipass/spawn/apps/handlers/file_ops.py +++ b/src/aipass/spawn/apps/handlers/file_ops.py @@ -23,6 +23,8 @@ SKIP_NAMES = {"__pycache__", ".git", ".template_registry.json"} def ensure_directory(path): """Create directory and parents if they don't exist.""" + if path is None: + raise ValueError("ensure_directory received None path — caller passed invalid target") Path(path).mkdir(parents=True, exist_ok=True) diff --git a/src/aipass/spawn/apps/handlers/passport_ops.py b/src/aipass/spawn/apps/handlers/passport_ops.py index b953c157..871130ce 100644 --- a/src/aipass/spawn/apps/handlers/passport_ops.py +++ b/src/aipass/spawn/apps/handlers/passport_ops.py @@ -98,9 +98,14 @@ def grant_passport( # Regenerate template registry regenerate_template_registry(target) - # Register in AIPASS_REGISTRY.json + # Register in AIPASS_REGISTRY.json (store relative path for portability) + try: + registry_branch_path = str(target.relative_to(reg_path.parent)) + except ValueError: + logger.warning("[passport] Cannot relativize path %s to registry %s, storing absolute", target, reg_path.parent) + registry_branch_path = str(target) registry_updated = add_to_registry( - reg_path, branch_upper, str(target), detected_profile, + reg_path, branch_upper, registry_branch_path, detected_profile, f"@{branch_lower}", purpose or "Birthright citizen - purpose TBD", ) diff --git a/src/aipass/spawn/apps/handlers/update_ops.py b/src/aipass/spawn/apps/handlers/update_ops.py index a68e3ea0..1978089e 100644 --- a/src/aipass/spawn/apps/handlers/update_ops.py +++ b/src/aipass/spawn/apps/handlers/update_ops.py @@ -36,8 +36,6 @@ from aipass.spawn.apps.handlers.placeholders import build_replacements_dict, rep from aipass.spawn.apps.handlers.registry import find_registry, load_registry, _branches_as_list from aipass.spawn.apps.handlers.json import json_handler -# Repo root — resolved from spawn package location -_REPO_ROOT = Path(__file__).parents[5] # handlers/apps/spawn/aipass/src/AIPass # ============================================================================= @@ -347,9 +345,10 @@ def _resolve_branch_path(branch_name: str) -> Path | None: """Resolve a branch name to its absolute directory path via the registry. Tries both the exact name and common case variants. - Registry paths are relative to repo root. + Registry paths are relative to registry parent (project root). """ registry_path = find_registry() + project_root = registry_path.parent registry = load_registry(registry_path) for branch in _branches_as_list(registry.get("branches", [])): @@ -357,7 +356,7 @@ def _resolve_branch_path(branch_name: str) -> Path | None: if reg_name.lower() == branch_name.lower(): rel_path = branch.get("path", "") if rel_path: - return (_REPO_ROOT / rel_path).resolve() + return (project_root / rel_path).resolve() return None diff --git a/src/aipass/spawn/templates/builder/.spawn/.template_registry.json b/src/aipass/spawn/templates/builder/.spawn/.template_registry.json index 270a0093..f4c09952 100644 --- a/src/aipass/spawn/templates/builder/.spawn/.template_registry.json +++ b/src/aipass/spawn/templates/builder/.spawn/.template_registry.json @@ -1,350 +1,390 @@ { "metadata": { - "description": "Template registry for tracking files", - "generated": true + "version": "1.0.0", + "last_updated": "2026-04-10", + "description": "Template file tracking registry for ID-based updates" }, "files": { "f001": { "path": ".ai_mail.local/README.md", "name": "README.md", - "content_hash": "49299c242a018d73", - "has_branch_placeholder": true + "content_hash": "49299c242a01", + "has_branch_placeholder": false }, "f002": { "path": ".ai_mail.local/inbox.json", "name": "inbox.json", - "content_hash": "c9702fe2cc21b748", + "content_hash": "c9702fe2cc21", "has_branch_placeholder": false }, "f003": { "path": ".aipass/README.md", "name": "README.md", - "content_hash": "f42d87684fdfb1af", - "has_branch_placeholder": true + "content_hash": "f42d87684fdf", + "has_branch_placeholder": false }, "f004": { "path": ".aipass/aipass_local_prompt.md", "name": "aipass_local_prompt.md", - "content_hash": "5f8e2a3859a99906", - "has_branch_placeholder": true + "content_hash": "5f8e2a3859a9", + "has_branch_placeholder": false }, "f005": { "path": ".archive/README.md", "name": "README.md", - "content_hash": "93d3fcb74f234b1d", - "has_branch_placeholder": true + "content_hash": "93d3fcb74f23", + "has_branch_placeholder": false }, "f006": { "path": ".claude/README.md", "name": "README.md", - "content_hash": "adb0ce8c53c1da2d", - "has_branch_placeholder": true + "content_hash": "adb0ce8c53c1", + "has_branch_placeholder": false }, "f007": { "path": ".claude/settings.local.json", "name": "settings.local.json", - "content_hash": "eacf065629cd9c03", + "content_hash": "eacf065629cd", "has_branch_placeholder": false }, "f008": { "path": ".gitignore", "name": ".gitignore", - "content_hash": "841dedb922da7ddb", + "content_hash": "841dedb922da", "has_branch_placeholder": false }, "f009": { "path": ".seedgo/README.md", "name": "README.md", - "content_hash": "ea03468bbf16f248", - "has_branch_placeholder": true + "content_hash": "ea03468bbf16", + "has_branch_placeholder": false }, "f010": { "path": ".seedgo/bypass.json", "name": "bypass.json", - "content_hash": "0ac90a35515b35c2", - "has_branch_placeholder": true + "content_hash": "0ac90a35515b", + "has_branch_placeholder": false + }, + "f024": { + "path": ".spawn/.registry_ignore.json", + "name": ".registry_ignore.json", + "content_hash": "34f5e7ff7e01", + "has_branch_placeholder": false + }, + "f043": { + "path": ".spawn/README.md", + "name": "README.md", + "content_hash": "e22ad5337efd", + "has_branch_placeholder": false }, "f011": { "path": ".trinity/README.md", "name": "README.md", - "content_hash": "f461c9b16fc566a0", - "has_branch_placeholder": true + "content_hash": "f461c9b16fc5", + "has_branch_placeholder": false }, "f012": { "path": ".trinity/local.json", "name": "local.json", - "content_hash": "f4e62d2d12bada2e", - "has_branch_placeholder": true + "content_hash": "f4e62d2d12ba", + "has_branch_placeholder": false }, "f013": { "path": ".trinity/observations.json", "name": "observations.json", - "content_hash": "25b957960a5939a3", - "has_branch_placeholder": true + "content_hash": "25b957960a59", + "has_branch_placeholder": false }, "f014": { "path": ".trinity/passport.json", "name": "passport.json", - "content_hash": "04c2ead2a4284027", - "has_branch_placeholder": true + "content_hash": "9b1d3a691272", + "has_branch_placeholder": false }, "f015": { "path": "CLAUDE.md", "name": "CLAUDE.md", - "content_hash": "e24c75348c4faa57", - "has_branch_placeholder": true + "content_hash": "e24c75348c4f", + "has_branch_placeholder": false }, "f016": { "path": "DASHBOARD.local.json", "name": "DASHBOARD.local.json", - "content_hash": "88360d943d4bacb6", - "has_branch_placeholder": true + "content_hash": "88360d943d4b", + "has_branch_placeholder": false }, "f017": { "path": "README.md", "name": "README.md", - "content_hash": "ad99517a50f96ba7", - "has_branch_placeholder": true + "content_hash": "ad99517a50f9", + "has_branch_placeholder": false }, "f018": { "path": "STATUS.local.md", "name": "STATUS.local.md", - "content_hash": "059295ad4d4e62eb", - "has_branch_placeholder": true + "content_hash": "059295ad4d4e", + "has_branch_placeholder": false }, "f019": { "path": "apps/README.md", "name": "README.md", - "content_hash": "92a956009e0e22dc", - "has_branch_placeholder": true + "content_hash": "92a956009e0e", + "has_branch_placeholder": false }, "f020": { "path": "apps/__init__.py", "name": "__init__.py", - "content_hash": "41b011f487af14dc", - "has_branch_placeholder": true + "content_hash": "41b011f487af", + "has_branch_placeholder": false }, "f021": { "path": "apps/handlers/README.md", "name": "README.md", - "content_hash": "2e4f4a0c1b474ffa", - "has_branch_placeholder": true + "content_hash": "2e4f4a0c1b47", + "has_branch_placeholder": false }, "f022": { "path": "apps/handlers/__init__.py", "name": "__init__.py", - "content_hash": "55afc5eddbe04134", - "has_branch_placeholder": true + "content_hash": "55afc5eddbe0", + "has_branch_placeholder": false }, "f023": { "path": "apps/modules/README.md", "name": "README.md", - "content_hash": "a4cf0a8e3b4ff993", - "has_branch_placeholder": true + "content_hash": "a4cf0a8e3b4f", + "has_branch_placeholder": false }, - "f024": { + "f044": { "path": "apps/modules/__init__.py", "name": "__init__.py", - "content_hash": "e3b0c44298fc1c14", + "content_hash": "e3b0c44298fc", "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", + "content_hash": "d1e4e2b98c38", "has_branch_placeholder": false }, "f027": { "path": "apps/{{BRANCH}}.py", "name": "{{BRANCH}}.py", - "content_hash": "fc71b424c10bb172", + "content_hash": "e3893ff95aa5", "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 + "content_hash": "de20d11e5cfd", + "has_branch_placeholder": false }, "f030": { "path": "docs/README.md", "name": "README.md", - "content_hash": "2434da568727499d", - "has_branch_placeholder": true + "content_hash": "2434da568727", + "has_branch_placeholder": false }, "f031": { "path": "docs.local/README.md", "name": "README.md", - "content_hash": "c19d8872ea2c2c25", - "has_branch_placeholder": true + "content_hash": "c19d8872ea2c", + "has_branch_placeholder": false }, "f032": { "path": "docs.local/sub_agent_drops/README.md", "name": "README.md", - "content_hash": "e3e5a6b9c9c51817", + "content_hash": "e3e5a6b9c9c5", "has_branch_placeholder": false }, "f033": { "path": "dropbox/README.md", "name": "README.md", - "content_hash": "9e1e9b71f93b4cde", - "has_branch_placeholder": true + "content_hash": "9e1e9b71f93b", + "has_branch_placeholder": false }, "f034": { "path": "logs/README.md", "name": "README.md", - "content_hash": "4ca207af6bd3a648", - "has_branch_placeholder": true + "content_hash": "4ca207af6bd3", + "has_branch_placeholder": false }, "f035": { "path": "pytest.ini", "name": "pytest.ini", - "content_hash": "7b39ba7bca4025c5", + "content_hash": "7b39ba7bca40", "has_branch_placeholder": false }, "f036": { "path": "templates/README.md", "name": "README.md", - "content_hash": "73b020b003f900e5", - "has_branch_placeholder": true + "content_hash": "73b020b003f9", + "has_branch_placeholder": false }, "f037": { "path": "tests/README.md", "name": "README.md", - "content_hash": "c157895c9b27d3ba", - "has_branch_placeholder": true + "content_hash": "c157895c9b27", + "has_branch_placeholder": false }, "f038": { "path": "tests/__init__.py", "name": "__init__.py", - "content_hash": "881f06bb6574d2f7", - "has_branch_placeholder": true + "content_hash": "881f06bb6574", + "has_branch_placeholder": false }, "f039": { "path": "tests/conftest.py", "name": "conftest.py", - "content_hash": "5d98b049957b24a4", + "content_hash": "5d98b049957b", "has_branch_placeholder": false }, "f040": { "path": "tools/README.md", "name": "README.md", - "content_hash": "3c7eaedb16ac2695", - "has_branch_placeholder": true + "content_hash": "3c7eaedb16ac", + "has_branch_placeholder": false }, "f041": { "path": "{{BRANCH}}_json/README.md", "name": "README.md", - "content_hash": "e64fa555e7b8e20f", - "has_branch_placeholder": true + "content_hash": "e64fa555e7b8", + "has_branch_placeholder": false }, "f042": { "path": "{{BRANCH}}_json/custom_config/README.md", "name": "README.md", - "content_hash": "28e9ae3735636611", - "has_branch_placeholder": true + "content_hash": "28e9ae373563", + "has_branch_placeholder": false + }, + "f029": { + "path": "artifacts/birth_certificate.json", + "name": "birth_certificate.json", + "content_hash": "0b6e4319781e", + "has_branch_placeholder": false + }, + "f026": { + "path": "apps/plugins/__init__.py", + "name": "__init__.py", + "content_hash": "e3b0c44298fc", + "has_branch_placeholder": false } }, "directories": { "d001": { "path": ".ai_mail.local", - "name": ".ai_mail.local" + "name": ".ai_mail.local", + "has_branch_placeholder": false }, "d002": { "path": ".ai_mail.local/sent", - "name": "sent" + "name": "sent", + "has_branch_placeholder": false }, "d003": { "path": ".aipass", - "name": ".aipass" + "name": ".aipass", + "has_branch_placeholder": false }, "d004": { "path": ".archive", - "name": ".archive" + "name": ".archive", + "has_branch_placeholder": false }, "d005": { "path": ".claude", - "name": ".claude" + "name": ".claude", + "has_branch_placeholder": false }, "d006": { "path": ".seedgo", - "name": ".seedgo" + "name": ".seedgo", + "has_branch_placeholder": false + }, + "d023": { + "path": ".spawn", + "name": ".spawn", + "has_branch_placeholder": false }, "d007": { "path": ".trinity", - "name": ".trinity" + "name": ".trinity", + "has_branch_placeholder": false }, "d008": { "path": "apps", - "name": "apps" + "name": "apps", + "has_branch_placeholder": false }, "d009": { "path": "apps/handlers", - "name": "handlers" + "name": "handlers", + "has_branch_placeholder": false }, "d010": { "path": "apps/modules", - "name": "modules" + "name": "modules", + "has_branch_placeholder": false }, "d011": { "path": "apps/plugins", - "name": "plugins" + "name": "plugins", + "has_branch_placeholder": false }, "d012": { "path": "artifacts", - "name": "artifacts" + "name": "artifacts", + "has_branch_placeholder": false }, "d013": { "path": "docs", - "name": "docs" + "name": "docs", + "has_branch_placeholder": false }, "d014": { "path": "docs.local", - "name": "docs.local" + "name": "docs.local", + "has_branch_placeholder": false }, "d015": { "path": "docs.local/sub_agent_drops", - "name": "sub_agent_drops" + "name": "sub_agent_drops", + "has_branch_placeholder": false }, "d016": { "path": "dropbox", - "name": "dropbox" + "name": "dropbox", + "has_branch_placeholder": false }, "d017": { "path": "logs", - "name": "logs" + "name": "logs", + "has_branch_placeholder": false }, "d018": { "path": "templates", - "name": "templates" + "name": "templates", + "has_branch_placeholder": false }, "d019": { "path": "tests", - "name": "tests" + "name": "tests", + "has_branch_placeholder": false }, "d020": { "path": "tools", - "name": "tools" + "name": "tools", + "has_branch_placeholder": false }, "d021": { "path": "{{BRANCH}}_json", - "name": "{{BRANCH}}_json" + "name": "{{BRANCH}}_json", + "has_branch_placeholder": true }, "d022": { "path": "{{BRANCH}}_json/custom_config", - "name": "custom_config" + "name": "custom_config", + "has_branch_placeholder": false } } } diff --git a/src/aipass/spawn/templates/builder/.trinity/passport.json b/src/aipass/spawn/templates/builder/.trinity/passport.json index 9b317bb9..049ec7d2 100644 --- a/src/aipass/spawn/templates/builder/.trinity/passport.json +++ b/src/aipass/spawn/templates/builder/.trinity/passport.json @@ -19,7 +19,7 @@ "identity": { "citizen_class": "builder", "role": "{{ROLE}}", - "purpose": "", + "purpose": "{{PURPOSE_BRIEF}}", "what_i_do": [], "what_i_dont_do": [] }, diff --git a/src/aipass/spawn/templates/builder/apps/{{BRANCH}}.py b/src/aipass/spawn/templates/builder/apps/{{BRANCH}}.py index ef38d681..c27e73c3 100644 --- a/src/aipass/spawn/templates/builder/apps/{{BRANCH}}.py +++ b/src/aipass/spawn/templates/builder/apps/{{BRANCH}}.py @@ -32,7 +32,7 @@ def discover_modules() -> List[Any]: if file_path.name.startswith("_"): continue - module_name = f"apps.modules.{file_path.stem}" + module_name = f"aipass.{{BRANCH}}.apps.modules.{file_path.stem}" try: module = importlib.import_module(module_name) diff --git a/src/aipass/spawn/templates/builder/artifacts/birth_certificate.json b/src/aipass/spawn/templates/builder/artifacts/birth_certificate.json index d465ade5..f61f1def 100644 --- a/src/aipass/spawn/templates/builder/artifacts/birth_certificate.json +++ b/src/aipass/spawn/templates/builder/artifacts/birth_certificate.json @@ -5,10 +5,10 @@ "creator": "SYSTEM", "owner": "{{BRANCHNAME}}", "rarity": "unique", - "description": "Official birth certificate for {{BRANCHNAME}}. Citizen #{{CITIZEN_NUMBER}}, registered using '{{PROFILE}}' template. Purpose: {{PURPOSE_BRIEF}}", + "description": "Official birth certificate for {{BRANCHNAME}}. Citizen #{{CITIZEN_NUMBER}}, builder class. Purpose: {{PURPOSE_BRIEF}}", "metadata": { "citizen_number": "{{CITIZEN_NUMBER}}", - "template": "{{PROFILE}}", + "citizen_class": "builder", "purpose": "{{PURPOSE_BRIEF}}" }, "created_at": "{{DATE}}" diff --git a/src/aipass/spawn/tests/test_lifecycle.py b/src/aipass/spawn/tests/test_lifecycle.py index 311e18e0..d06ee40a 100644 --- a/src/aipass/spawn/tests/test_lifecycle.py +++ b/src/aipass/spawn/tests/test_lifecycle.py @@ -119,8 +119,7 @@ class TestDeleteBranch: """Successful delete should archive the branch and remove from registry.""" from aipass.spawn.apps.handlers.delete_ops import delete_branch - with patch("aipass.spawn.apps.handlers.delete_ops._REPO_ROOT", repo_root), \ - patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry): + with patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry): result = delete_branch("test_api", confirm=False) @@ -146,8 +145,7 @@ class TestDeleteBranch: """Cannot delete spawn (self-protection).""" from aipass.spawn.apps.handlers.delete_ops import delete_branch - with patch("aipass.spawn.apps.handlers.delete_ops._REPO_ROOT", repo_root), \ - patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry): + with patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry): result = delete_branch("spawn", confirm=False) @@ -158,8 +156,7 @@ class TestDeleteBranch: """Cannot delete devpulse (orchestration hub protection).""" from aipass.spawn.apps.handlers.delete_ops import delete_branch - with patch("aipass.spawn.apps.handlers.delete_ops._REPO_ROOT", repo_root), \ - patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry): + with patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry): result = delete_branch("devpulse", confirm=False) @@ -170,8 +167,7 @@ class TestDeleteBranch: """Cannot delete drone (routing infrastructure protection).""" from aipass.spawn.apps.handlers.delete_ops import delete_branch - with patch("aipass.spawn.apps.handlers.delete_ops._REPO_ROOT", repo_root), \ - patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry): + with patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry): result = delete_branch("drone", confirm=False) @@ -182,8 +178,7 @@ class TestDeleteBranch: """Dry run should NOT delete or archive anything.""" from aipass.spawn.apps.handlers.delete_ops import delete_branch - with patch("aipass.spawn.apps.handlers.delete_ops._REPO_ROOT", repo_root), \ - patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry): + with patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry): result = delete_branch("test_api", confirm=False, dry_run=True) @@ -202,8 +197,7 @@ class TestDeleteBranch: """Deleting a branch not in registry should fail gracefully.""" from aipass.spawn.apps.handlers.delete_ops import delete_branch - with patch("aipass.spawn.apps.handlers.delete_ops._REPO_ROOT", repo_root), \ - patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry): + with patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry): result = delete_branch("nonexistent", confirm=False) @@ -214,8 +208,7 @@ class TestDeleteBranch: """Cancelling confirmation should not delete.""" from aipass.spawn.apps.handlers.delete_ops import delete_branch - with patch("aipass.spawn.apps.handlers.delete_ops._REPO_ROOT", repo_root), \ - patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry), \ + with patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry), \ patch("builtins.input", return_value="n"): result = delete_branch("test_api", confirm=True) @@ -736,8 +729,7 @@ class TestHandleDelete: """handle_delete should reject protected branches.""" from aipass.spawn.apps.modules.delete import handle_delete - with patch("aipass.spawn.apps.handlers.delete_ops._REPO_ROOT", repo_root), \ - patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry): + with patch("aipass.spawn.apps.handlers.delete_ops.find_registry", return_value=mock_registry): result = handle_delete(["--yes", "@spawn"]) diff --git a/src/aipass/spawn/tests/test_update.py b/src/aipass/spawn/tests/test_update.py index 2ebfad63..1e161475 100644 --- a/src/aipass/spawn/tests/test_update.py +++ b/src/aipass/spawn/tests/test_update.py @@ -201,8 +201,7 @@ class TestUpdateBranch: assert not meta_path.exists() with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \ - patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \ - patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path): + patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry): result = update_branch("test_branch") @@ -220,8 +219,7 @@ class TestUpdateBranch: dashboard_before = (branch_dir / "DASHBOARD.local.json").read_text() with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \ - patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \ - patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path): + patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry): result = update_branch("test_branch", dry_run=True) @@ -248,8 +246,7 @@ class TestUpdateBranch: original_content = branch_py_content with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \ - patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \ - patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path): + patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry): result = update_branch("test_branch") @@ -282,8 +279,7 @@ class TestUpdateBranch: reg_path.write_text(json.dumps(reg, indent=2) + "\n") with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \ - patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \ - patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path): + patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry): result = update_branch("test_branch") @@ -302,8 +298,7 @@ class TestUpdateBranch: from aipass.spawn.apps.handlers.update_ops import update_branch with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \ - patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \ - patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path): + patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry): result = update_branch("nonexistent_branch") @@ -329,8 +324,7 @@ class TestUpdateBranch: reg_path.write_text(json.dumps(reg, indent=2) + "\n") with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \ - patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \ - patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path): + patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry): result = update_branch("test_branch") @@ -373,8 +367,7 @@ class TestUpdateBranch: save_branch_meta(branch_dir, meta) with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \ - patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \ - patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path): + patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry): result = update_branch("test_branch") @@ -398,8 +391,7 @@ class TestUpdateAll: from aipass.spawn.apps.handlers.update_ops import update_all with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \ - patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \ - patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path): + patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry): results = update_all() @@ -434,8 +426,7 @@ class TestUpdateAll: mock_registry.write_text(json.dumps(reg, indent=2) + "\n") with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \ - patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \ - patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path): + patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry): results = update_all() @@ -459,8 +450,7 @@ class TestHandleUpdate: from aipass.spawn.apps.modules.update import handle_update with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \ - patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \ - patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path): + patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry): result = handle_update(["@test_branch"]) @@ -471,8 +461,7 @@ class TestHandleUpdate: from aipass.spawn.apps.modules.update import handle_update with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \ - patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \ - patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path): + patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry): result = handle_update(["--dry-run", "@test_branch"]) @@ -493,8 +482,7 @@ class TestHandleUpdate: from aipass.spawn.apps.modules.update import handle_update with patch("aipass.spawn.apps.handlers.update_ops.get_template_dir", return_value=template_dir), \ - patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry), \ - patch("aipass.spawn.apps.handlers.update_ops._REPO_ROOT", tmp_path): + patch("aipass.spawn.apps.handlers.update_ops.find_registry", return_value=mock_registry): result = handle_update(["builder", "--all"])