From e167c3fa448bb54fa45f04f0bba2b625722fd768 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Fri, 8 May 2026 22:54:32 -0700 Subject: [PATCH 1/2] feat(system): DPLAN-0168 Phase 5: doctor auto-wire for provider settings Co-Authored-By: @devpulse --- src/aipass/aipass/apps/modules/doctor.py | 99 ++++++- src/aipass/aipass/apps/modules/doctor_wire.py | 274 ++++++++++++++++++ src/aipass/aipass/tests/test_doctor.py | 2 +- 3 files changed, 363 insertions(+), 12 deletions(-) create mode 100644 src/aipass/aipass/apps/modules/doctor_wire.py diff --git a/src/aipass/aipass/apps/modules/doctor.py b/src/aipass/aipass/apps/modules/doctor.py index dc5b43b6..fc19286b 100644 --- a/src/aipass/aipass/apps/modules/doctor.py +++ b/src/aipass/aipass/apps/modules/doctor.py @@ -18,9 +18,9 @@ Flutter-doctor-style health check across four groups: Three-tier glyph output: ✓ green / ! yellow / ✗ red Remediation shown inline under failing checks. Exit 0 on pass+warn, non-zero only on errors. -Pure reads — never mutates. +Pure reads — never mutates (unless --fix or interactive auto-wire accepted). -Run: aipass doctor [--verbose] +Run: aipass doctor [--verbose] [--fix] """ from __future__ import annotations @@ -36,6 +36,11 @@ from aipass.cli.apps.modules import console from aipass.prax import logger from aipass.aipass.apps.handlers.json import json_handler +from aipass.aipass.apps.modules.doctor_wire import ( + ENV_DESCRIPTIONS, + HOOK_DESCRIPTIONS, + _auto_wire_provider, +) from aipass.aipass.apps.handlers.system_detect.system_detector import ( detect_cpu, detect_git, @@ -59,7 +64,6 @@ from aipass.aipass.apps.handlers.ui.progress import ( _BRANCH_ROOT = Path(__file__).resolve().parents[2] - class CheckResult(NamedTuple): """Single doctor check result.""" @@ -224,7 +228,7 @@ def _find_manifest() -> Path | None: return None -def _check_provider_manifest() -> List[CheckResult]: +def _check_provider_manifest(interactive: bool = False, fix: bool = False) -> List[CheckResult]: """Check provider settings against manifest. Returns hook/env/permission results.""" results: List[CheckResult] = [] @@ -275,6 +279,7 @@ def _check_provider_manifest() -> List[CheckResult]: ) # --- Env vars in provider settings --- + missing_env: List[str] = [] manifest_env = claude_section.get("env", {}) if manifest_env: provider_settings_path = Path.home() / ".claude" / "settings.json" @@ -299,6 +304,8 @@ def _check_provider_manifest() -> List[CheckResult]: ) # --- Permissions --- + missing_deny: List[str] = [] + missing_ask: List[str] = [] manifest_perms = claude_section.get("permissions", {}) manifest_deny = manifest_perms.get("deny", []) manifest_ask = manifest_perms.get("ask", []) @@ -333,10 +340,78 @@ def _check_provider_manifest() -> List[CheckResult]: ) ) + # --- Interactive auto-wire prompt / --fix auto-accept --- + if (interactive or fix) and any(r.glyph != GLYPH_PASS for r in results): + if fix: + # --fix skips prompt, auto-wires directly + actions = _auto_wire_provider(manifest_path, interactive=False) + for action in actions: + console.print(f"[green]✓[/green] {action}") + else: + _prompt_auto_wire(manifest_path, results, missing_hooks, missing_env, missing_deny, missing_ask) + return results -def _check_services(verbose: bool = False) -> List[CheckResult]: +def _prompt_auto_wire( + manifest_path: Path, + results: List[CheckResult], + missing_hooks: List[str], + missing_env: List[str], + missing_deny: List[str], + missing_ask: List[str], +) -> None: + """Prompt user to auto-wire provider settings, or print manual warning.""" + hook_count = len(missing_hooks) + env_count = len(missing_env) + perm_count = len(missing_deny) + len(missing_ask) + logger.warning("[doctor] %d hooks, %d env vars, %d permissions missing", hook_count, env_count, perm_count) + console.print(f"\n[bold]{hook_count} hooks, {env_count} env vars, {perm_count} permissions missing[/bold]") + console.print("[dim]Review details: .claude/hooks/README.md[/dim]") + + try: + answer = input("Auto-wire provider settings? [y/N]: ").strip().lower() + except (EOFError, KeyboardInterrupt) as exc: + logger.info("[doctor] auto-wire prompt interrupted: %s", type(exc).__name__) + answer = "n" + + if answer in ("y", "yes"): + actions = _auto_wire_provider(manifest_path, interactive=True) + for action in actions: + console.print(f"[green]✓[/green] {action}") + else: + _print_manual_wire_warning(missing_hooks, missing_env, missing_deny, missing_ask) + + +def _print_manual_wire_warning( + missing_hooks: List[str], + missing_env: List[str], + missing_deny: List[str], + missing_ask: List[str], +) -> None: + """Print detailed warning when user declines auto-wire.""" + logger.warning("[doctor] provider settings not wired — user declined auto-wire") + console.print("\n[bold]Provider settings not wired. Required for full AIPass functionality:[/bold]\n") + if missing_hooks: + console.print("[bold]Hooks (code quality enforcement):[/bold]") + for hook in missing_hooks: + desc = HOOK_DESCRIPTIONS.get(hook, hook) + console.print(f" [dim]•[/dim] {hook} — {desc}") + console.print() + if missing_env: + console.print("[bold]Env vars:[/bold]") + for var in missing_env: + desc = ENV_DESCRIPTIONS.get(var, var) + console.print(f" [dim]•[/dim] {var} — {desc}") + console.print() + if missing_deny or missing_ask: + console.print(f"{len(missing_deny)} deny rules + {len(missing_ask)} ask rules" + " (protect ~/.secrets/, block destructive git)") + console.print() + console.print("[dim]Wire manually when ready — see .claude/hooks/README.md[/dim]") + + +def _check_services(verbose: bool = False, interactive: bool = False, fix: bool = False) -> List[CheckResult]: """Run Services group checks.""" results: List[CheckResult] = [] @@ -397,7 +472,7 @@ def _check_services(verbose: bool = False) -> List[CheckResult]: results.append(CheckResult("pytest collect", GLYPH_WARN, "timed out", "")) # hooks + env + permissions — manifest-driven provider check - manifest_checks = _check_provider_manifest() + manifest_checks = _check_provider_manifest(interactive=interactive, fix=fix) results.extend(manifest_checks) return results @@ -434,7 +509,7 @@ def _check_community() -> List[CheckResult]: # ============================================================================= -def run_doctor(verbose: bool = False) -> int: +def run_doctor(verbose: bool = False, interactive: bool = False, fix: bool = False) -> int: """Run all four groups and print results. Returns error count.""" console.print() console.print("[bold cyan]aipass doctor[/bold cyan]") @@ -445,7 +520,7 @@ def run_doctor(verbose: bool = False) -> int: group_specs = [ ("System", _check_system), ("Identity", _check_identity), - ("Services", lambda: _check_services(verbose=verbose)), + ("Services", lambda: _check_services(verbose=verbose, interactive=interactive, fix=fix)), ("Community", _check_community), ] groups: Dict[str, List[CheckResult]] = {} @@ -530,8 +605,9 @@ def print_help() -> None: console.print() console.print("[yellow]USAGE:[/yellow]") - console.print(" [green]aipass doctor[/green] [dim]# Run all checks[/dim]") + console.print(" [green]aipass doctor[/green] [dim]# Run all checks (interactive)[/dim]") console.print(" [green]aipass doctor --verbose[/green] [dim]# Show sub-check detail[/dim]") + console.print(" [green]aipass doctor --fix[/green] [dim]# Auto-wire missing provider settings[/dim]") console.print() console.print("[yellow]OUTPUT:[/yellow]") @@ -573,8 +649,9 @@ def handle_command(command: str, args: list[str]) -> bool: return True verbose = "--verbose" in args or "-v" in args - error_count = run_doctor(verbose=verbose) - json_handler.log_operation("doctor_run", {"error_count": error_count}) + fix_mode = "--fix" in args + error_count = run_doctor(verbose=verbose, interactive=True, fix=fix_mode) + json_handler.log_operation("doctor_run", {"error_count": error_count, "fix": fix_mode}) if error_count > 0: raise SystemExit(1) return True diff --git a/src/aipass/aipass/apps/modules/doctor_wire.py b/src/aipass/aipass/apps/modules/doctor_wire.py new file mode 100644 index 00000000..c8e61ed8 --- /dev/null +++ b/src/aipass/aipass/apps/modules/doctor_wire.py @@ -0,0 +1,274 @@ +# =================== AIPass ==================== +# Name: doctor_wire.py +# Description: Auto-wire provider settings from manifest into user config +# Version: 1.0.0 +# Created: 2026-05-08 +# Modified: 2026-05-08 +# ============================================= + +""" +doctor_wire — auto-wire provider settings + +Extracted from doctor.py to keep module sizes manageable. +Provides: + - HOOK_DESCRIPTIONS / ENV_DESCRIPTIONS — human-readable hook/env purpose + - _resolve_hook_source() — locate hook scripts (repo or pip package) + - _auto_wire_provider() — additive merge of manifest into ~/.claude/settings.json +""" + +from __future__ import annotations + +import json +import os +import shutil +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, List + +from aipass.cli.apps.modules import console +from aipass.prax import logger + +from aipass.aipass.apps.handlers.json import json_handler + +# ============================================================================= +# HOOK & ENV DESCRIPTIONS (for interactive "no" warning) +# ============================================================================= + +HOOK_DESCRIPTIONS: Dict[str, str] = { + "pre_edit_gate.py": "blocks edits outside agent's branch", + "subagent_stop_gate.py": "validates agent output on exit", + "auto_fix_diagnostics.py": "auto-fixes lint issues after edits", + "global_prompt_loader.py": "injects branch context on each turn", + "identity_injector.py": "injects agent identity on each turn", + "email_notification.py": "notifies on incoming agent mail", + "branch_prompt_loader.py": "loads branch-specific prompts", + "pre_compact.py": "saves state before context compaction", +} + +ENV_DESCRIPTIONS: Dict[str, str] = { + "AIPASS_HOME": "tells agents where AIPass lives", + "CLAUDE_CODE_DISABLE_AUTO_MEMORY": "prevents conflict with .trinity/ memory system", +} + + +# ============================================================================= +# HOOK SOURCE RESOLUTION +# ============================================================================= + + +def _resolve_hook_source(manifest_path: Path) -> Path | None: + """Find where hook scripts live. + + First: look for ``.claude/hooks/`` relative to the manifest path (clone/fork users). + Fallback: find the pip-installed package location via ``aipass/_hooks/``. + Returns the directory Path, or None if neither found. + """ + # Repo-local hooks (manifest lives in .claude/, hooks are in .claude/hooks/) + repo_hooks = manifest_path.parent / "hooks" + if repo_hooks.is_dir(): + return repo_hooks + + # Pip-installed package — _hooks directory next to top-level aipass __init__.py + try: + import importlib.resources as _resources + + ref = _resources.files("aipass").joinpath("_hooks") + pkg_hooks = Path(str(ref)) + if pkg_hooks.is_dir(): + return pkg_hooks + except Exception as exc: + logger.info("[doctor] importlib.resources lookup failed: %s", exc) + + # Manual fallback: walk up from this file to find the aipass package root + pkg_root = Path(__file__).resolve() + for _ in range(10): + pkg_root = pkg_root.parent + candidate = pkg_root / "_hooks" + if candidate.is_dir(): + return candidate + if pkg_root == pkg_root.parent: + break + + return None + + +# ============================================================================= +# AUTO-WIRE +# ============================================================================= + + +def _auto_wire_provider(manifest_path: Path, interactive: bool = True) -> List[str]: + """Auto-wire provider settings from manifest into ~/.claude/settings.json. + + Additive merge only — never removes or overwrites existing keys/values. + Returns list of action descriptions (for logging/display). + """ + actions: List[str] = [] + + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + claude_section = manifest.get("cli", {}).get("claude", {}) + if not claude_section: + return actions + + # Read existing settings + settings_path = Path.home() / ".claude" / "settings.json" + if settings_path.exists(): + settings = json.loads(settings_path.read_text(encoding="utf-8")) + else: + settings = {} + + # Backup + if settings_path.exists(): + date_stamp = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + backup_path = settings_path.with_suffix(f".json.bak.{date_stamp}") + shutil.copy2(settings_path, backup_path) + actions.append(f"Backed up settings to {backup_path.name}") + + # Hooks — copy user-source scripts and add settings entries + manifest_hooks = claude_section.get("hooks", []) + hook_source_dir = _resolve_hook_source(manifest_path) + user_hooks_dir = Path.home() / ".claude" / "hooks" + + for hook in manifest_hooks: + script = hook.get("script", "") + if not script: + continue + source_type = hook.get("source", "repo") + + # Only user-source hooks get copied to ~/.claude/hooks/ + if source_type == "user": + target = user_hooks_dir / script + if not target.exists() and hook_source_dir: + src_file = hook_source_dir / script + if src_file.exists(): + os.makedirs(user_hooks_dir, exist_ok=True) + shutil.copy2(src_file, target) + actions.append(f"Copied hook {script} to ~/.claude/hooks/") + + # Add hook entry to settings.json if not already present + if "hooks" not in settings: + settings["hooks"] = {} + event = hook.get("event", "") + if event not in settings["hooks"]: + settings["hooks"][event] = [] + event_hooks = settings["hooks"][event] + if not isinstance(event_hooks, list): + event_hooks = [event_hooks] + settings["hooks"][event] = event_hooks + + already_wired = any( + isinstance(h, dict) and script in h.get("command", "") + for h in event_hooks + ) + if not already_wired: + if source_type == "user": + hook_path = f"~/.claude/hooks/{script}" + else: + hook_path = f".claude/hooks/{script}" + entry: Dict[str, object] = { + "type": "command", + "command": f"{sys.executable} {hook_path}", + } + if hook.get("matcher"): + entry["matcher"] = hook["matcher"] + if hook.get("timeout"): + entry["timeout"] = hook["timeout"] + event_hooks.append(entry) + actions.append(f"Wired hook {script} -> {event}") + + # Env vars + manifest_env = claude_section.get("env", {}) + if manifest_env: + if "env" not in settings: + settings["env"] = {} + repo_root = str(manifest_path.parent.parent) + project_root = str(Path.cwd()) + for key, value in manifest_env.items(): + if key not in settings["env"]: + resolved = value.replace("{{REPO_ROOT}}", repo_root) + resolved = resolved.replace("{{PROJECT_ROOT}}", project_root) + settings["env"][key] = resolved + actions.append(f"Set env {key}={resolved}") + + # Permissions + manifest_perms = claude_section.get("permissions", {}) + manifest_deny = manifest_perms.get("deny", []) + manifest_ask = manifest_perms.get("ask", []) + + if manifest_deny or manifest_ask: + if "permissions" not in settings: + settings["permissions"] = {} + if "deny" not in settings["permissions"]: + settings["permissions"]["deny"] = [] + if "ask" not in settings["permissions"]: + settings["permissions"]["ask"] = [] + + existing_deny = set(settings["permissions"]["deny"]) + for rule in manifest_deny: + if rule not in existing_deny: + settings["permissions"]["deny"].append(rule) + actions.append(f"Added deny rule: {rule}") + + existing_ask = set(settings["permissions"]["ask"]) + for rule in manifest_ask: + if rule not in existing_ask: + settings["permissions"]["ask"].append(rule) + actions.append(f"Added ask rule: {rule}") + + # Write settings back + os.makedirs(settings_path.parent, exist_ok=True) + settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8") + actions.append("Updated ~/.claude/settings.json") + + return actions + + +# ============================================================================= +# OUTPUT FORMATTING +# ============================================================================= + + +def print_introspection() -> None: + """Display module info for doctor_wire.""" + console.print() + console.print("[bold cyan]doctor_wire Module[/bold cyan]") + console.print("Auto-wire provider settings from manifest into user config") + console.print() + console.print("[yellow]Provides:[/yellow]") + console.print(" [dim]- HOOK_DESCRIPTIONS / ENV_DESCRIPTIONS[/dim]") + console.print(" [dim]- _resolve_hook_source() — locate hook scripts[/dim]") + console.print(" [dim]- _auto_wire_provider() — additive merge into settings[/dim]") + console.print() + + +# ============================================================================= +# COMMAND HANDLER +# ============================================================================= + + +def handle_command(command: str, args: list[str]) -> bool: + """Handle command routing. This is a helper module — no standalone commands. + + Args: + command: Command name. + args: Additional arguments. + + Returns: + True if handled, False otherwise. + """ + if command != "doctor_wire": + return False + + if not args: + print_introspection() + json_handler.log_operation("doctor_wire_info", {"command": command}) + return True + + if args[0] in ("--info", "info"): + print_introspection() + json_handler.log_operation("doctor_wire_info", {"command": command}) + return True + + json_handler.log_operation("doctor_wire_noop", {"command": command}) + return False diff --git a/src/aipass/aipass/tests/test_doctor.py b/src/aipass/aipass/tests/test_doctor.py index d1dcc9a1..f546cc0a 100644 --- a/src/aipass/aipass/tests/test_doctor.py +++ b/src/aipass/aipass/tests/test_doctor.py @@ -332,7 +332,7 @@ class TestDoctorHandleCommand: with patch("aipass.aipass.apps.modules.doctor.run_doctor", return_value=0) as mock_run: with patch("aipass.aipass.apps.modules.doctor.json_handler"): handle_command("doctor", ["--verbose"]) - mock_run.assert_called_once_with(verbose=True) + mock_run.assert_called_once_with(verbose=True, interactive=True, fix=False) # ============================================================================= From 694c9e7a2d7b81b7f1d1e840c6ee650030726292 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Fri, 8 May 2026 23:10:29 -0700 Subject: [PATCH 2/2] feat(system): DPLAN-0168 Phase 5: doctor auto-wire + re-check after wiring + sub-agent PR rule Co-Authored-By: @devpulse --- .claude/CLAUDE.md | 2 ++ src/aipass/aipass/apps/modules/doctor.py | 21 +++++++++++++++------ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 97e6d001..da057df6 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -90,6 +90,8 @@ Every branch is an expert in its domain. When you don't know something, ask the Branches operate semi-autonomously. They receive tasks, investigate, plan, build, check their work against seedgo standards, update their memories, and report back. The system teaches itself through this cycle. +**Sub-agents build, managers PR.** Sub-agents never create PRs — they build the code and run tests. The manager (devpulse or the dispatching branch) reviews the work, then creates the PR. The review gate is the manager's responsibility. + --- ## Message from the AIPass Developer diff --git a/src/aipass/aipass/apps/modules/doctor.py b/src/aipass/aipass/apps/modules/doctor.py index fc19286b..3e372701 100644 --- a/src/aipass/aipass/apps/modules/doctor.py +++ b/src/aipass/aipass/apps/modules/doctor.py @@ -342,13 +342,17 @@ def _check_provider_manifest(interactive: bool = False, fix: bool = False) -> Li # --- Interactive auto-wire prompt / --fix auto-accept --- if (interactive or fix) and any(r.glyph != GLYPH_PASS for r in results): + wired = False if fix: - # --fix skips prompt, auto-wires directly actions = _auto_wire_provider(manifest_path, interactive=False) for action in actions: console.print(f"[green]✓[/green] {action}") + wired = bool(actions) else: - _prompt_auto_wire(manifest_path, results, missing_hooks, missing_env, missing_deny, missing_ask) + wired = _prompt_auto_wire(manifest_path, results, missing_hooks, missing_env, missing_deny, missing_ask) + + if wired: + return _check_provider_manifest(interactive=False, fix=False) return results @@ -360,8 +364,11 @@ def _prompt_auto_wire( missing_env: List[str], missing_deny: List[str], missing_ask: List[str], -) -> None: - """Prompt user to auto-wire provider settings, or print manual warning.""" +) -> bool: + """Prompt user to auto-wire provider settings, or print manual warning. + + Returns True if wiring was performed. + """ hook_count = len(missing_hooks) env_count = len(missing_env) perm_count = len(missing_deny) + len(missing_ask) @@ -379,8 +386,10 @@ def _prompt_auto_wire( actions = _auto_wire_provider(manifest_path, interactive=True) for action in actions: console.print(f"[green]✓[/green] {action}") - else: - _print_manual_wire_warning(missing_hooks, missing_env, missing_deny, missing_ask) + return bool(actions) + + _print_manual_wire_warning(missing_hooks, missing_env, missing_deny, missing_ask) + return False def _print_manual_wire_warning(